diff --git a/.gitignore b/.gitignore
index e1bf1dc..1d69868 100644
--- a/.gitignore
+++ b/.gitignore
@@ -163,3 +163,5 @@ cython_debug/
data/
test.bmp
+# Photoshop files
+image-source/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 90d16b8..8ef0c3c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,11 +1,11 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.3.0
+ rev: v5.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
- rev: 22.6.0
+ rev: 25.1.0
hooks:
- id: black
diff --git a/Dockerfile b/Dockerfile
index 6c348a7..2adbdc1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,7 @@ COPY .env /cncnet-map-api/
# Copy files needed for build
COPY requirements.txt /cncnet-map-api
COPY requirements-dev.txt /cncnet-map-api
-COPY start.sh /cncnet-map-api
+COPY web_entry_point.sh /cncnet-map-api
RUN apt-get update && apt-get install -y liblzo2-dev # Compression library used by westwood.
RUN apt-get install libmagic1 # File type checking.
@@ -18,5 +18,5 @@ RUN pip install --upgrade pip
# The cflags are needed to build the lzo library on Apple silicon.
RUN CFLAGS=-I$(brew --prefix)/include LDFLAGS=-L$(brew --prefix)/lib pip install -r ./requirements-dev.txt
-RUN chmod +x /cncnet-map-api/start.sh
-ENTRYPOINT "/cncnet-map-api/start.sh"
+RUN chmod +x /cncnet-map-api/web_entry_point.sh
+ENTRYPOINT "/cncnet-map-api/web_entry_point.sh"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..e98c5b3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+#desolate:
+# POSTGRES_DATA_DIR = $(shell cat .env | grep POSTGRES_DATA_DIR)
+
+serve:
+ docker compose build django
+ docker compose up nginx-server
+
+stop:
+ docker compose stop
+
+test:
+ docker compose build django
+ docker compose run test
diff --git a/README.md b/README.md
index ef6d6a0..69c39fe 100644
--- a/README.md
+++ b/README.md
@@ -17,27 +17,32 @@ The mascot for the backend API is Kirovy, by [Direct & Dominate](https://www.you
## Frontend devs
-Just set up your environment file and run the full docker compose.
+Just set up your environment file and run `docker compose up web -d`.
+
+This will launch the database, run the migrations, and start the django web server.
[Example env file](example.env)
## Backend devs
-You can use the docker files if you'd like, but Django + docker is known to have issue attaching
-to debuggers and hitting breakpoints, so here are the native OS instructions.
+You can use docker compose if you'd like, but here are the native OS instructions.
+
+### Linux and Mac
1. Download and install [pyenv](https://github.com/pyenv/pyenv)
+ > You don't have to use `pyenv` but it makes life much easier when dealing with virtual environments.
2. Install [PostgreSQL](https://www.postgresql.org/) for your system. This is required for Django
- On Mac you can do `brew install postgresql` if you have brew installed.
3. Install LibMagic for [Python Magic](https://github.com/ahupp/python-magic)
- - On Mac you can do `brew install libmagic` if you have breq installed.
+ - On Mac you can do `brew install libmagic` if you have brew installed.
+ > LibMagic is used for checking file types.
4. Checkout the repository
5. Switch to the repository directory
6. Setup Python
- Install Python 3.12 `pyenv install 3.12` or whatever the latest python is.
- Setup the virtual environments `pyenv virtualenv 3.12 cncnet-map-api`
- - Set the virtual enviornment for the directory `pyenv local cncnet-map-api`
-7. Setup requirements `pip install -r requirements-dev.txt`
+ - Set the virtual environment for the directory `pyenv local cncnet-map-api`
+7. Install the dev requirements `pip install -r requirements-dev.txt`
- On Apple Silicon you'll need to install lzo with `brew install lzo` then run
`CFLAGS=-I$(brew --prefix)/include LDFLAGS=-L$(brew --prefix)/lib pip install -r requirements-dev.txt`
to get `python-lzo` to install. You shouldn't need to include those flags again unless `python-lzo` updates.
@@ -49,23 +54,37 @@ to debuggers and hitting breakpoints, so here are the native OS instructions.
- If the app doesn't run due to a missing required variable, add said variable to `example.env` because the person
who made the variable forgot to do so.
10. Run the `db` service in `docker-compose`
-11. Load your `.env` file into your shell, (you can use `source load_env.sh && read_env`)
+11. Load your `.env` file into your shell, (you can use `source load_env.sh && read_env`)
then migrate the database `./manage.py migrate`
-12. `./manage.py runserver`
+12. Run the django server with `./manage.py runserver`
+
+Tests can be run by following [these instructions](#running-tests-backend-devs)
+
+
+### Windows
+
+Chairman Bing of the Massivesoft corporation strikes again; getting the `LZO` libraries running
+natively on Windows is a... less-than-pleasant effort. So use docker instead.
+
+1. Install docker for windows. I have had success with [Rancher Desktop](https://rancherdesktop.io/)
+ or [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/)
+2. After docker is running, switch to your local git repo and run `docker compose up windows-dev -d`.
+ Make sure the build succeeds.
+3. Set `windows-dev` as your python interpreter for whichever editor you use.
-You can technically use PyCharm to launch everything via `docker-compose`, but there is some
-weird issue with breakpoints not triggering.
+> [!TIP]
+> In Pycharm you go to `Settings > Project > Python Interpreter > Add Interpreter > Docker Compose`
-## Running tests
+## Running tests (backend devs)
-I **strongly** recommend using PyCharm and the `.env` plugin for running the PyTests.
+I strongly recommend using PyCharm (or any other Python IDE with breakpoints) and the `.env` plugin for running the PyTests.
All you need to do is run the database from `docker-compose`, then launch the tests via PyCharm.
**If you want to run the tests via CLI:**
- Make sure your database is running from the docker compose file. `docker-compose start db`
-- Make sure your environment variables are setup and loaded to your shell. See [backend dev setup](#backend-devs)
+- Make sure your environment variables are setup and loaded to your shell. See [backend dev setup](#load-shell-env)
- Run `DJANGO_SETTINGS_MODULE="kirovy.settings.testing" pytest tests`
Django should automatically run migrations as part of the test startup.
diff --git a/ci.env b/ci.env
index aef3f2c..469a82d 100644
--- a/ci.env
+++ b/ci.env
@@ -3,8 +3,8 @@ POSTGRES_USER=kane
POSTGRES_PASSWORD=cidevelopmentpasswordtechnologyofpeace
POSTGRES_PORT=5432
POSTGRES_DATA_DIR=/data/db/
-MEDIA_ROOT=/data/cnc_net_files/
-STATIC_ROOT=/data/cnc_net_static/
+HOST_MEDIA_ROOT=/data/cnc_net_files/
+HOST_STATIC_ROOT=/data/cnc_net_static/
POSTGRES_TEST_HOST=db
SECRET_KEY=";thetechnologyofpeaceforcidevwork6352722!@#$$#@"
RUN_ENVIRONMENT=ci
diff --git a/docker-compose.yml b/docker-compose.yml
index bcd1e62..efa7507 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3.9"
-
services:
db:
image: postgres
@@ -14,18 +12,35 @@ services:
ports:
- "127.0.0.1:${POSTGRES_PORT}:${POSTGRES_PORT}"
command: -p ${POSTGRES_PORT}
- web:
+
+ django:
+ # Won't serve files on its own. Launch nginx-server to run the whoe app.
build: .
volumes:
- .:/cncnet-map-api
- - ${MEDIA_ROOT}:/data/cncnet_files
- - ${STATIC_ROOT}:/data/cncnet_static
+ - ${HOST_MEDIA_ROOT}:/data/cncnet_silo # django will save user-uploaded files here. MEDIA_ROOT
+ - ${HOST_STATIC_ROOT}:/data/cncnet_static # django will gather static files here. STATIC_ROOT
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
+
+ nginx-server:
+ # This is the prod server service.
+ # `docker compose up nginx -d` will run the whole app.
+ # nginx proxies requests to django via gunicorn.
+ image: nginx:latest
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ${HOST_STATIC_ROOT}:/usr/share/nginx/html/static # website static assets.
+ - ${HOST_MEDIA_ROOT}:/usr/share/nginx/html/silo # website user-uploaded files.
+ ports:
+ - "80:80"
+ depends_on:
+ - django
+
test:
build:
context: ./
@@ -40,3 +55,18 @@ services:
- db
command:
- pytest
+
+ windows-dev:
+ # Use this for windows. The LZO libraries are annoying to deal with without using docker.
+ # Chairman Bing of Massivesoft strikes again.
+ build:
+ context: ./
+ dockerfile: test.DockerFile
+ volumes:
+ - .:/cncnet-map-api
+ env_file:
+ - .env
+ environment:
+ POSTGRES_TEST_HOST: db # Necessary to connect to docker db. Overrides the .env setting.
+ depends_on:
+ - db
diff --git a/example.env b/example.env
index 4e914fd..6d2a2ea 100644
--- a/example.env
+++ b/example.env
@@ -19,12 +19,18 @@ POSTGRES_TEST_HOST=localhost
# Port for postgres
POSTGRES_PORT=5432
-# Required. The location where the app data (maps and such) will be saved. The docker volume mounts here
-MEDIA_ROOT=./data/cncnet_files/
-
-# Required. The location where static files will be served from.
-# Make sure to configure the webserver to serve files from here and run ``./manage.py collectstatic``
-STATIC_ROOT=./data/cncnet_static/
+# Required. The location where the app data (maps and such) will be saved on the docker host.
+# The docker volume mounts here.
+# You should have this backed up because it's where user-uploaded files will live.
+# nginx and django will serve these files under the url ``/silo/``
+HOST_MEDIA_ROOT=./data/cncnet_silo/
+
+# Required. The directory where collected static files will be saved on the docker host.
+# The docker volume mounts here.
+# ``collectstatic`` will gather all static assets here to be served by nginx.
+# Remember to run ``./manage.py collectstatic`` during build so that this directory will be populated.
+# nginx and django will serve these files under the url ``/static/``
+HOST_STATIC_ROOT=./data/cncnet_static/
# Django debug, never enable on prod
DEBUG=0
diff --git a/kirovy/exceptions/__init__.py b/kirovy/exceptions/__init__.py
index cf10cc9..388dfe4 100644
--- a/kirovy/exceptions/__init__.py
+++ b/kirovy/exceptions/__init__.py
@@ -1,6 +1,7 @@
"""
All exceptions for our app belong in this package.
"""
+
from django.core.exceptions import * # Import django exceptions for use elsewhere.
from typing import Optional
from django.utils.translation import gettext_lazy as _
@@ -74,3 +75,12 @@ def __init__(
code: Optional[int] = None,
):
super().__init__(game_name_or_slug, detail, code)
+
+
+class BanException(Exception):
+ """Raised when there is an issue during the ban process.
+
+ ``str(e)`` will be returned to the UI.
+ """
+
+ pass
diff --git a/kirovy/models/__init__.py b/kirovy/models/__init__.py
index 8277506..73da8c9 100644
--- a/kirovy/models/__init__.py
+++ b/kirovy/models/__init__.py
@@ -1,5 +1,14 @@
+import typing
+
+from django.db.models import UUIDField, Model
+
from .cnc_game import CncGame, CncFileExtension
from .cnc_map import CncMap, CncMapFile, MapCategory
from .cnc_user import CncUser
from .file_base import CncNetFileBaseModel
from .map_preview import MapPreview
+
+
+class SupportsBan(typing.Protocol):
+ def set_ban(self, is_banned: bool, banned_by: CncUser) -> None:
+ ...
diff --git a/kirovy/models/cnc_map.py b/kirovy/models/cnc_map.py
index 97aad6b..dbb3adc 100644
--- a/kirovy/models/cnc_map.py
+++ b/kirovy/models/cnc_map.py
@@ -7,7 +7,7 @@
from kirovy.models import file_base
from kirovy.models import cnc_game as game_models, cnc_user
from kirovy.models.cnc_base_model import CncNetBaseModel
-from kirovy import typing as t
+from kirovy import typing as t, exceptions
class MapCategory(CncNetBaseModel):
@@ -160,6 +160,12 @@ def get_map_directory_path(self) -> pathlib.Path:
self.id.hex,
)
+ def set_ban(self, is_banned: bool, banned_by: cnc_user.CncUser) -> None:
+ if self.is_legacy:
+ raise exceptions.BanException("legacy-maps-cannot-be-banned")
+ self.is_banned = is_banned
+ self.save(update_fields=["is_banned"])
+
class CncMapFile(file_base.CncNetFileBaseModel):
"""Represents the actual map file that a Command & Conquer game reads."""
diff --git a/kirovy/models/cnc_user.py b/kirovy/models/cnc_user.py
index 2d3f9f3..4c22fb1 100644
--- a/kirovy/models/cnc_user.py
+++ b/kirovy/models/cnc_user.py
@@ -162,6 +162,11 @@ def create_or_update_from_cncnet(user_dto: CncnetUserInfo) -> "CncUser":
return kirovy_user
+ def set_ban(self, is_banned: bool, banned_by: "CncUser") -> None:
+ # TODO: bannable objects should probably be an abstract class
+ self.is_banned = is_banned
+ self.save(update_fields=["is_banned"])
+
class CncNetUserOwnedModel(CncNetBaseModel):
"""A mixin model for any models that will be owned by a user."""
diff --git a/kirovy/objects/ui_objects.py b/kirovy/objects/ui_objects.py
new file mode 100644
index 0000000..b88dc07
--- /dev/null
+++ b/kirovy/objects/ui_objects.py
@@ -0,0 +1,149 @@
+"""
+Defines custom objects to be sent between the UI and backend.
+
+Not necessary for endpoint that take one parameter in the Post, but still recommended.
+"""
+
+import enum
+from typing import TypedDict, NotRequired, List
+
+from django.views import View
+from pydantic import BaseModel
+
+from kirovy import typing as t, models
+from kirovy.permissions import StaticPermission, IsStaff, CanUpload, IsAdmin
+from kirovy.request import KirovyRequest
+
+from kirovy.typing import DictStrAny
+
+
+class BanData(BaseModel):
+ """For ban post requests.
+
+ - View: :class:`kirovy.views.admin_views.BanView`
+ - URL: ``/admin/ban``
+ """
+
+ class Meta:
+ class ObjectType(str, enum.Enum):
+ USER = "user"
+ MAP = "map"
+
+ OBJECT_MODEL_MAP = {
+ ObjectType.MAP: models.CncMap,
+ ObjectType.USER: models.CncUser,
+ }
+
+ def get_model(self) -> models.SupportsBan:
+ return self.Meta.OBJECT_MODEL_MAP[self.object_type]
+
+ object_type: Meta.ObjectType
+ is_banned: bool
+ object_id: str
+
+
+class PaginationMetadata(TypedDict):
+ """Metadata returned to the UI with paginated responses."""
+
+ offset: int
+ limit: NotRequired[int]
+ remaining_count: NotRequired[int]
+
+
+class BaseResponseData(TypedDict):
+ """Basic response from Kirovy to the UI.
+
+ Mostly used for post requests where the only side effect is some kind of success or failure message.
+ """
+
+ message: NotRequired[str]
+
+
+class ListResponseData(BaseResponseData):
+ """Kirovy response to the UI for ``list`` endpoints.
+
+ e.g. listing all users.
+
+ Pagination is not required but may be present.
+ """
+
+ results: List[DictStrAny]
+ pagination_metadata: NotRequired[PaginationMetadata]
+
+
+class ResponseData(BaseResponseData):
+ """Basic response that returns a dictionary in addition to the message from ``BaseResponseData``.
+
+ Mostly subclassed for endpoints that return side effects to the UI.
+ """
+
+ result: DictStrAny
+
+
+class ErrorResponseData(BaseResponseData):
+ """Basic response that returns a dictionary of additional data related to an error."""
+
+ additional: NotRequired[DictStrAny]
+
+
+class UiPermissions:
+ """A class to hold permissions to send to the UI.
+
+ Used for rendering buttons and such. Does not control anything in Kirovy itself.
+ The actual backend views will use the regular
+ [DRF permission workflow](https://www.django-rest-framework.org/api-guide/permissions/).
+ """
+
+ SHOW_STAFF_CONTROLS: t.Final[str] = "show_staff_controls"
+ SHOW_UPLOAD_BUTTON: t.Final[str] = "show_upload_button"
+ SHOW_ADMIN_CONTROLS: t.Final[str] = "show_admin_controls"
+
+ static_permissions: t.Dict[t.UiPermissionName, StaticPermission] = {
+ SHOW_STAFF_CONTROLS: IsStaff,
+ SHOW_UPLOAD_BUTTON: CanUpload,
+ SHOW_ADMIN_CONTROLS: IsAdmin,
+ }
+ """attr: The dictionary structure that gets returned to the UI.
+
+ The UI will see e.g.:
+
+ .. code-block:: json
+
+ {
+ "show_staff_controls": true,
+ "show_upload_button": true,
+ "show_admin_controls": false,
+ }
+ """
+
+ @classmethod
+ def render_static(
+ cls, request: KirovyRequest, view: View
+ ) -> t.Dict[t.UiPermissionName, bool]:
+ """Create a dictionary of permissions to tell the UI what to display.
+
+ This **DOES NOT** control the backend permissions, it's just to help the UI know which buttons to show.
+ If someone finds a way to show the buttons anyway, then kirovy will still block the request with the actual
+ permission checks on the views.
+
+ :param request:
+ The request for the API call.
+ :param view:
+ The view instance itself.
+ :return:
+ The dictionary of permission names with a bool representing if the user has that permission.
+ e.g.:
+
+ .. code-block:: json
+
+ {
+ "show_staff_controls": true,
+ "show_upload_button": true,
+ "show_admin_controls": false,
+ }
+ """
+ ui_permissions: t.Dict[t.UiPermissionName, bool] = {}
+ for ui_name, permission_cls in cls.static_permissions.items():
+ ui_permissions[ui_name] = permission_cls().has_permission(request, view)
+
+ return ui_permissions
diff --git a/kirovy/permissions.py b/kirovy/permissions.py
index f07734f..8fdff64 100644
--- a/kirovy/permissions.py
+++ b/kirovy/permissions.py
@@ -4,7 +4,6 @@
from rest_framework import permissions
from kirovy.models import cnc_user
-from kirovy.models.cnc_base_model import CncNetBaseModel
from kirovy.request import KirovyRequest
_C = t.TypeVar("_C", bound=t.Callable)
@@ -102,45 +101,3 @@ class IsAdmin(permissions.IsAuthenticated):
def has_permission(self, request: KirovyRequest, view: View) -> bool:
return super().has_permission(request, view) and request.user.is_admin
-
-
-class UiPermissions:
- """A class to hold permissions to send to the UI.
-
- Used for rendering buttons and such. Does not control anything in Kirovy itself.
- The actual backend views will use the regular
- [DRF permission workflow](https://www.django-rest-framework.org/api-guide/permissions/).
- """
-
- SHOW_STAFF_CONTROLS: t.Final[str] = "show_staff_controls"
- SHOW_UPLOAD_BUTTON: t.Final[str] = "show_upload_button"
- SHOW_ADMIN_CONTROLS: t.Final[str] = "show_admin_controls"
-
- static_permissions: t.Dict[t.UiPermissionName, StaticPermission] = {
- SHOW_STAFF_CONTROLS: IsStaff,
- SHOW_UPLOAD_BUTTON: CanUpload,
- SHOW_ADMIN_CONTROLS: IsAdmin,
- }
-
- @classmethod
- def render_static(
- cls, request: KirovyRequest, view: View
- ) -> t.Dict[t.UiPermissionName, bool]:
- """Create a dictionary of permissions to tell the UI what to display.
-
- This **DOES NOT** control the backend permissions, it's just to help the UI know which buttons to show.
- If someone finds a way to show the buttons anyway, then kirovy will still block the request with the actual
- permission checks on the views.
-
- :param request:
- The request for the API call.
- :param view:
- The view instance itself.
- :return:
- The dictionary of permission names with a bool representing if the user has that permission.
- """
- ui_permissions: t.Dict[t.UiPermissionName, bool] = {}
- for ui_name, permission_cls in cls.static_permissions.items():
- ui_permissions[ui_name] = permission_cls().has_permission(request, view)
-
- return ui_permissions
diff --git a/kirovy/response.py b/kirovy/response.py
index 52a7ebe..16ec7db 100644
--- a/kirovy/response.py
+++ b/kirovy/response.py
@@ -1,11 +1,13 @@
from rest_framework.response import Response
+
+import kirovy.objects.ui_objects
from kirovy import typing as t
class KirovyResponse(Response):
def __init__(
self,
- data: t.Optional[t.BaseResponseData] = None,
+ data: t.Optional[kirovy.objects.ui_objects.BaseResponseData] = None,
status: t.Optional[int] = None,
template_name: t.Optional[str] = None,
headers: t.Optional[t.DictStrAny] = None,
diff --git a/kirovy/services/cnc_gen_2_services.py b/kirovy/services/cnc_gen_2_services.py
index 473a567..03b47bd 100644
--- a/kirovy/services/cnc_gen_2_services.py
+++ b/kirovy/services/cnc_gen_2_services.py
@@ -174,6 +174,8 @@ def is_binary(cls, uploaded_file: File) -> bool:
def is_text(cls, uploaded_file: File) -> bool:
"""Check if a file is readable as text.
+ Also checks for ``ini`` files if the host OS supports them.
+
:param uploaded_file:
The supposed map file
:return:
@@ -183,7 +185,7 @@ def is_text(cls, uploaded_file: File) -> bool:
uploaded_file.seek(0)
mr_mime = magic_parser.from_buffer(uploaded_file.read())
uploaded_file.seek(0)
- return mr_mime == "text/plain"
+ return mr_mime in {"text/plain", "application/x-wine-extension-ini"}
def extract_preview(self) -> t.Optional[Image.Image]:
"""Extract the map preview if it exists.
diff --git a/kirovy/settings/_base.py b/kirovy/settings/_base.py
index c9e079d..71077c9 100644
--- a/kirovy/settings/_base.py
+++ b/kirovy/settings/_base.py
@@ -85,6 +85,7 @@
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = {
+ # If you use docker compose then these should be defined for you.
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": get_env_var("POSTGRES_DB"),
@@ -130,29 +131,56 @@
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/
-STATIC_URL = "static/"
-"""str: The static directory for serving web files. This is for assets for the website, **not** for uploads."""
-
CNC_GAME_IMAGE_DIRECTORY = "game_images/"
"""
str: The directory inside of :attr:`~kirovy.settings._base.STATIC_URL` where we store game-specific and mod-specific
-logos and backgrounds.
+logos and backgrounds. So a Red Alert 2 icon would be in e.g. ``URL/static/game_images/ra2/icons/allies.png``
"""
-MEDIA_ROOT = get_env_var("MEDIA_ROOT")
+CNC_MAP_DIRECTORY = "maps"
+""":attr: The directory, beneath the game slug, where map files will be stored."""
+
+
+### --------------- SERVING FILES ---------------
+### This section of settings has to do with serving files
+### I recommend having `docker-compose.yml` and `nginx.conf` open if you're trying to decipher these settings.
+
+MEDIA_ROOT = "/data/cncnet_silo/"
""":attr: The directory where all user uploads will be stored."""
-MEDIA_URL = get_env_var("MEDIA_URL", default="downloads/")
-"""str: The URL path that ``settings.MEDIA_ROOT`` files will be served from."""
+MEDIA_URL = "silo/"
+"""str: The URL path that ``settings.MEDIA_ROOT`` files will be served from.
+The URL will be ``HOST/silo/``
-CNC_MAP_DIRECTORY = "maps"
-""":attr: The directory, beneath the game slug, where map files will be stored."""
+Matches the path in :file:`nginx.conf`.
+"""
+
+STATIC_URL = "static/"
+"""str: The URL path for serving web files. This is for assets for the website, **not** for uploads.
+
+This also doubles as the repo directory name for where kirovy stores static files before ``collectstatic`` gathers
+them to be served by nginx.
+"""
STATICFILES_DIRS = (Path(BASE_DIR, STATIC_URL),)
-""":attr: Where uploaded files will be stored."""
+""":attr: Directories to gather as part of ``collectstatic``.
+
+All assets listed here will be bundled into a single directory -- defined by ``STATIC_ROOT`` -- as part of
+the ``collectstatiic`` command during the build process.
+"""
+
+STATIC_ROOT = "/data/cncnet_static"
+"""attr: The directory where django will gather static files to when ``collectstatic`` is run.
+
+``collectstatic`` is a command run as part of the build process. It gathers the assets from the
+:attr:`kirovy.settings._base.STATICFILES_DIRS` and merges them all into one place for serving via nginx.
+This is necessary because django app dependencies -- like the admin plugin -- have their own static assets.
+
+Basically, ``collectstatic`` copies static files from your project directories to the web server's exposed directory.
+"""
-STATIC_ROOT = get_env_var("STATIC_ROOT")
+### ------------- END SERVING FILES -------------
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@@ -163,3 +191,4 @@
RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "dev")
+"""attr: Defines which type of environment we are running on. Useful for debug logic."""
diff --git a/kirovy/typing/__init__.py b/kirovy/typing/__init__.py
index 834fb09..d3a6228 100644
--- a/kirovy/typing/__init__.py
+++ b/kirovy/typing/__init__.py
@@ -28,25 +28,5 @@
""":attr: A string passed to the UI to show/hide certain features. Does not control backend permissions, this is just
for UX."""
-
-class PaginationMetadata(TypedDict):
- offset: int
- limit: NotRequired[int]
- remaining_count: NotRequired[int]
-
-
-class BaseResponseData(TypedDict):
- message: NotRequired[str]
-
-
-class ListResponseData(BaseResponseData):
- results: List[DictStrAny]
- pagination_metadata: NotRequired[PaginationMetadata]
-
-
-class ResponseData(BaseResponseData):
- result: DictStrAny
-
-
-class ErrorResponseData(BaseResponseData):
- additional: NotRequired[DictStrAny]
+NO_VALUE = object()
+""":attr: Used for cases where you want an optional parameter, but ``None`` is a valid value."""
diff --git a/kirovy/urls.py b/kirovy/urls.py
index 9fdffca..e7a44b3 100644
--- a/kirovy/urls.py
+++ b/kirovy/urls.py
@@ -13,16 +13,17 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
-from kirovy.views import test, cnc_map_views, permission_views
+from kirovy.views import test, cnc_map_views, permission_views, admin_views
from kirovy import typing as t
-def _get_url_patterns() -> t.List[path]:
+def _get_url_patterns() -> list[path]:
"""Return the root level url patterns.
I added this because I wanted to have the root URLs at the top of the file,
@@ -30,7 +31,7 @@ def _get_url_patterns() -> t.List[path]:
"""
return (
[
- path("admin/", admin.site.urls),
+ path("admin/", include(admin_patterns)),
path("test/jwt", test.TestJwt.as_view()),
path(
"ui-permissions/", permission_views.ListPermissionForAuthUser.as_view()
@@ -44,16 +45,26 @@ def _get_url_patterns() -> t.List[path]:
)
+# /maps/
map_patterns = [
# path("categories/", ...), # return all categories
# path("categories/game//", ...),
path("categories/", cnc_map_views.MapCategoryListCreateView.as_view()),
path("upload/", cnc_map_views.MapFileUploadView.as_view()),
path("/", cnc_map_views.MapRetrieveUpdateView.as_view()),
+ path("delete//", cnc_map_views.MapDeleteView.as_view()),
# path("img//"),
# path("img//", ...),
# path("search/")
]
+# /users/
+user_patterns = [
+ # path("")
+]
+
+# /admin/
+admin_patterns = [path("ban/", admin_views.BanView.as_view())]
+
urlpatterns = _get_url_patterns()
diff --git a/kirovy/views/admin_views.py b/kirovy/views/admin_views.py
new file mode 100644
index 0000000..5812771
--- /dev/null
+++ b/kirovy/views/admin_views.py
@@ -0,0 +1,51 @@
+import pydantic
+from rest_framework import status
+from rest_framework.generics import get_object_or_404
+from rest_framework.views import APIView
+
+from kirovy import permissions, exceptions
+from kirovy.objects import ui_objects
+from kirovy.request import KirovyRequest
+from kirovy.response import KirovyResponse
+
+
+class BanView(APIView):
+ """The view for banning things.
+
+ ``POST /admin/ban/``
+
+ Payload :attr:`kirovy.objects.ui_objects.BanData`.
+ """
+
+ http_method_names = ["post"]
+ permission_classes = [permissions.IsStaff]
+
+ def post(self, request: KirovyRequest, **kwargs) -> KirovyResponse:
+ if not request.data:
+ return KirovyResponse(
+ status=status.HTTP_400_BAD_REQUEST,
+ data=ui_objects.ErrorResponseData(message="no_data"),
+ )
+ try:
+ ban_data = ui_objects.BanData(**request.data)
+ except pydantic.ValidationError:
+ return KirovyResponse(
+ status=status.HTTP_400_BAD_REQUEST,
+ data=ui_objects.ErrorResponseData(message="data_failed_validation"),
+ )
+
+ obj = get_object_or_404(
+ ban_data.get_model().objects.filter(), id=ban_data.object_id
+ )
+ try:
+ obj.set_ban(ban_data.is_banned, self.request.user)
+ except exceptions.BanException as e:
+ return KirovyResponse(
+ status=status.HTTP_400_BAD_REQUEST,
+ data=ui_objects.ErrorResponseData(message=str(e)),
+ )
+
+ return KirovyResponse(
+ status=status.HTTP_200_OK,
+ data=ui_objects.ResponseData(message="", result=ban_data.model_dump()),
+ )
diff --git a/kirovy/views/base_views.py b/kirovy/views/base_views.py
index 09b34af..77c5d3b 100644
--- a/kirovy/views/base_views.py
+++ b/kirovy/views/base_views.py
@@ -10,6 +10,7 @@
)
from rest_framework.response import Response
+import kirovy.objects.ui_objects
from kirovy import permissions, typing as t
from kirovy.request import KirovyRequest
from kirovy.response import KirovyResponse
@@ -23,9 +24,9 @@ class KirovyDefaultPagination(_pagination.LimitOffsetPagination):
max_limit = 200
def get_paginated_response(self, results: t.List[t.DictStrAny]) -> Response:
- data = t.ListResponseData(
+ data = kirovy.objects.ui_objects.ListResponseData(
results=results,
- pagination_metadata=t.PaginationMetadata(
+ pagination_metadata=kirovy.objects.ui_objects.PaginationMetadata(
offset=self.offset,
limit=self.limit,
remaining_count=self.count,
@@ -72,7 +73,7 @@ def list(self, request, *args, **kwargs):
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
- data = t.ListResponseData(results=serializer.data)
+ data = kirovy.objects.ui_objects.ListResponseData(results=serializer.data)
return Response(data, status=status.HTTP_200_OK)
def get_paginated_response(self, data: t.List[t.DictStrAny]) -> Response:
@@ -103,7 +104,7 @@ def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return KirovyResponse(
- t.ResponseData(
+ kirovy.objects.ui_objects.ResponseData(
result=serializer.data,
),
status=status.HTTP_200_OK,
diff --git a/kirovy/views/cnc_map_views.py b/kirovy/views/cnc_map_views.py
index 9f5200e..62af31a 100644
--- a/kirovy/views/cnc_map_views.py
+++ b/kirovy/views/cnc_map_views.py
@@ -6,9 +6,11 @@
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
from django.db.models import Q
from rest_framework import status
+from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser
from rest_framework.views import APIView
+import kirovy.objects.ui_objects
from kirovy import permissions, typing as t, exceptions, constants
from kirovy.models import (
MapCategory,
@@ -82,7 +84,14 @@ def get_queryset(self):
class MapDeleteView(base_views.KirovyDestroyView):
- ...
+ queryset = CncMap.objects.filter()
+
+ def perform_destroy(self, instance: CncMap):
+ if instance.is_legacy:
+ raise PermissionDenied(
+ "cannot-delete-legacy-maps", status.HTTP_403_FORBIDDEN
+ )
+ return super().perform_destroy(instance)
class MapFileUploadView(APIView):
@@ -100,7 +109,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
if uploaded_size > max_size:
return KirovyResponse(
- t.ErrorResponseData(
+ kirovy.objects.ui_objects.ErrorResponseData(
message="File too large",
additional={
"max_bytes": str(max_size),
@@ -115,7 +124,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
map_parser = CncGen2MapParser(uploaded_file)
except exceptions.InvalidMapFile as e:
return KirovyResponse(
- t.ErrorResponseData(message="Invalid Map File"),
+ kirovy.objects.ui_objects.ErrorResponseData(message="Invalid Map File"),
status=status.HTTP_400_BAD_REQUEST,
)
@@ -196,7 +205,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
# TODO: Actually serialize the return data and include the link to the preview.
# TODO: Should probably convert this to DRF for that step.
return KirovyResponse(
- t.ResponseData(
+ kirovy.objects.ui_objects.ResponseData(
message="File uploaded successfully",
result={
"cnc_map": new_map.map_name,
diff --git a/kirovy/views/permission_views.py b/kirovy/views/permission_views.py
index f5a1311..28b1b41 100644
--- a/kirovy/views/permission_views.py
+++ b/kirovy/views/permission_views.py
@@ -1,6 +1,7 @@
from rest_framework import status
from rest_framework.views import APIView
+import kirovy.objects.ui_objects
from kirovy import permissions, typing as t
from kirovy.request import KirovyRequest
from kirovy.response import KirovyResponse
@@ -19,7 +20,7 @@ class ListPermissionForAuthUser(APIView):
]
def get(self, request: KirovyRequest, *args, **kwargs) -> KirovyResponse:
- data = t.ResponseData(
- result=permissions.UiPermissions.render_static(request, self)
+ data = kirovy.objects.ui_objects.ResponseData(
+ result=kirovy.objects.ui_objects.UiPermissions.render_static(request, self)
)
return KirovyResponse(data=data, status=status.HTTP_200_OK)
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..40340e0
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,34 @@
+user nginx;
+worker_processes 4;
+pid /var/run/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ server {
+ listen 80;
+
+ # Serve static files: js, static images, etc.
+ location /static/ {
+ alias /usr/share/nginx/html/static/; # The nginx container's mounted volume.
+ expires 30d;
+ add_header Cache-Control public;
+ }
+
+ # Serve user uploaded files
+ location /silo/ {
+ alias /usr/share/nginx/html/silo/; # The container's mounted volume.
+ }
+
+ # Proxy requests to the Django app running in gunicorn
+ location / {
+ proxy_pass http://django:8000; # The Django app is exposed on the `django` container on port 8000
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6289872
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,8 @@
+[tool.black]
+line-length = 120 # GitHub's line length. Black will help prevent you from needing to right-scroll.
+target-version = ['py312']
+
+[project]
+requires-python = "3.12.4"
+name = "cncnet-map-api"
+dynamic = ["version"]
diff --git a/requirements.txt b/requirements.txt
index 22b395d..0449c97 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-django>=4.2.11,<=4.3 # 4.2 is the Long-Term Service version until 2026.
+django>=4.2.11,<4.3 # 4.2 is the Long-Term Service version until 2026.
psycopg2==2.*
requests>=2.31,<3.0
djangorestframework>=3.14,<4.0
@@ -7,3 +7,5 @@ pillow==10.*
python-lzo==1.15
ujson==5.*
python-magic>=0.4.27
+pydantic>=2.10,<3.0
+gunicorn>=22.0.0,<23.0.0
diff --git a/start.sh b/start.sh
deleted file mode 100755
index aa40362..0000000
--- a/start.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-python manage.py collectstatic --no-input
-python manage.py migrate
-python manage.py runserver 0.0.0.0:8000
diff --git a/test.DockerFile b/test.DockerFile
index 63b09af..65adc14 100644
--- a/test.DockerFile
+++ b/test.DockerFile
@@ -11,7 +11,7 @@ COPY .env /cncnet-map-api/
# Copy files needed for build
COPY requirements.txt /cncnet-map-api
COPY requirements-dev.txt /cncnet-map-api
-COPY start.sh /cncnet-map-api
+COPY web_entry_point.sh /cncnet-map-api
RUN apt-get update && apt-get install -y liblzo2-dev
RUN pip install --upgrade pip
diff --git a/tests/fixtures/common_fixtures.py b/tests/fixtures/common_fixtures.py
index 478b53e..e1f46cb 100644
--- a/tests/fixtures/common_fixtures.py
+++ b/tests/fixtures/common_fixtures.py
@@ -1,19 +1,24 @@
import datetime
import json
+import pathlib
+from random import randint
from unittest import mock
import pytest
import requests
import ujson
+from django.conf import UserSettingsHolder
from django.contrib.auth.models import AnonymousUser
+from django.http import FileResponse
from django.test import Client
-from django.conf import (
- settings as _settings,
-) # Need to rename to not conflict with setting fixture.
+from pytest_django.fixtures import settings
from pytest_django.lazy_django import skip_if_no_django
+from rest_framework import status
from kirovy import objects, typing as t, constants
from kirovy.models import CncUser
+from kirovy.objects.ui_objects import ErrorResponseData
+from kirovy.response import KirovyResponse
@pytest.fixture
@@ -31,14 +36,14 @@ def _inner(bearer_token: str) -> dict:
@pytest.fixture
-def jwt_header(create_auth_header):
+def jwt_header(create_auth_header, settings):
"""Generates headers for calls to JWT protected endpoints.
You need an account on CncNet and the environment variables for the credentials set.
"""
email_pass = {
- "email": _settings.TESTING_API_USERNAME,
- "password": _settings.TESTING_API_PASSWORD,
+ "email": settings.TESTING_API_USERNAME,
+ "password": settings.TESTING_API_PASSWORD,
}
response = requests.post("https://ladder.cncnet.org/api/v1/auth/login", email_pass)
@@ -50,8 +55,12 @@ def jwt_header(create_auth_header):
@pytest.fixture(autouse=True)
def tmp_media_root(tmp_path, settings):
- """Makes all file uploads go to tmp paths to not fill up developer drives."""
- tmp_media = tmp_path / settings.MEDIA_ROOT
+ """Makes all file uploads go to tmp paths to not fill up developer drives.
+
+ Modifies
+ """
+ # If the path begins in a slash then it will override the temp path, so we need to make sure it doesn't.
+ tmp_media = tmp_path / settings.MEDIA_ROOT.lstrip("/")
if not tmp_media.exists():
tmp_media.mkdir(parents=True)
settings.MEDIA_ROOT = tmp_media
@@ -62,6 +71,8 @@ class KirovyClient(Client):
"""A client wrapper with defaults I prefer.
Our whole API will be JSON, so all write methods are wrapped to default to JSON.
+
+ **Don't construct manually**, use the ``client_*`` fixtures.
"""
cncnet_user_info: t.Optional[objects.CncnetUserInfo] = None
@@ -101,15 +112,28 @@ def set_active_user(
self.kirovy_user = kirovy_user
self.cncnet_user_info = cncnet_user_info
- def request(self, **request):
+ def request(self, **request) -> KirovyResponse | FileResponse:
"""Wraps request to mock the authenticate method to return our "active" user."""
- with mock.patch(
- "kirovy.authentication.CncNetAuthentication.authenticate"
- ) as mocked:
+ with mock.patch("kirovy.authentication.CncNetAuthentication.authenticate") as mocked:
if not self.kirovy_user:
mocked.return_value = None
else:
mocked.return_value = self.kirovy_user, self.cncnet_user_info
+
+ # Local imports are usually bad, but we don't want to interfere with the settings fixture.
+ from django.conf import settings as _settings
+
+ # Emulate serving files so that we don't need to run a full WSGI stack for pytest.
+ if (url_path := request.get("PATH_INFO", "")) and url_path.startswith(_settings.MEDIA_URL):
+ # This file path relies on the ``tmp_media_root`` fixture switching ``MEDIA_ROOT`` to the test path.
+ # ``tmp_media_root`` is set to ``autouse=True`` so this shouldn't have issues.
+ file_path: pathlib.Path = _settings.MEDIA_ROOT / url_path.replace(_settings.MEDIA_URL, "")
+ if not file_path.exists() or not file_path.is_file():
+ return KirovyResponse(
+ data=ErrorResponseData(message="file-not-found"), status=status.HTTP_404_NOT_FOUND
+ )
+ return FileResponse(file_path.open("rb"), as_attachment=True)
+
return super().request(**request)
def post(
@@ -187,7 +211,7 @@ def put(
@pytest.fixture
-def create_client(db):
+def create_client(db, tmp_media_root):
"""Return a factory to create a kirovy test http client.
The db fixture is included because you'll probably want DB access
@@ -218,7 +242,7 @@ def create_kirovy_user(db):
"""Return a user creation factory."""
def _inner(
- cncnet_id: int = 686,
+ cncnet_id: int = None,
username: str = "EbullientPrism",
verified_map_uploader: bool = True,
verified_email: bool = True,
@@ -234,6 +258,8 @@ def _inner(
See the model for param descriptions.
:class:`~kirovy.models.cnc_user.CncUser`
"""
+ if cncnet_id is None:
+ cncnet_id = randint(1, 999999999)
user = CncUser(
cncnet_id=cncnet_id,
username=username,
@@ -289,25 +315,19 @@ def non_verified_user(create_kirovy_user) -> CncUser:
@pytest.fixture
def moderator(create_kirovy_user) -> CncUser:
"""Convenience method to create a moderator."""
- return create_kirovy_user(
- cncnet_id=117649, username="DespondentPyre", group=constants.CncnetUserGroup.MOD
- )
+ return create_kirovy_user(cncnet_id=117649, username="DespondentPyre", group=constants.CncnetUserGroup.MOD)
@pytest.fixture
def admin(create_kirovy_user) -> CncUser:
"""Convenience method to create an admin."""
- return create_kirovy_user(
- cncnet_id=49, username="MendicantBias", group=constants.CncnetUserGroup.ADMIN
- )
+ return create_kirovy_user(cncnet_id=49, username="MendicantBias", group=constants.CncnetUserGroup.ADMIN)
@pytest.fixture
def god(create_kirovy_user) -> CncUser:
"""Convenience method to create a god."""
- return create_kirovy_user(
- cncnet_id=1, username="ThePrimordial", group=constants.CncnetUserGroup.GOD
- )
+ return create_kirovy_user(cncnet_id=1, username="ThePrimordial", group=constants.CncnetUserGroup.GOD)
@pytest.fixture
diff --git a/tests/fixtures/map_fixtures.py b/tests/fixtures/map_fixtures.py
index cfbd5ee..583b743 100644
--- a/tests/fixtures/map_fixtures.py
+++ b/tests/fixtures/map_fixtures.py
@@ -1,4 +1,6 @@
-from kirovy.models import CncGame
+from django.db.models import UUIDField
+
+from kirovy.models import CncGame, CncUser
from kirovy.models.cnc_map import CncMap, CncMapFile, MapCategory
from kirovy import typing as t
import pytest
@@ -35,7 +37,7 @@ def cnc_map_category(create_cnc_map_category) -> MapCategory:
@pytest.fixture
-def create_cnc_map(db, cnc_map_category, game_yuri):
+def create_cnc_map(db, cnc_map_category, game_yuri, client_user):
"""Return a function to create a CncMap object."""
def _inner(
@@ -43,6 +45,8 @@ def _inner(
description: str = "A fun map. Capture the center airports for a Hind.",
cnc_game: CncGame = game_yuri,
map_categories: t.List[MapCategory] = None,
+ user_id: t.Union[UUIDField, str, None, t.NO_VALUE] = t.NO_VALUE,
+ is_legacy: bool = False,
) -> CncMap:
"""Create a CncMap object.
@@ -55,9 +59,14 @@ def _inner(
:param map_categories:
The categories the map falls under. These can be found in the map file INI
for the 2d C&C games. ``Basic.GameMode``. Many-to-many
+ :param user_id:
+ The user who owns the map. ``None`` is a valid option.
+ Defaults to the user from the :func:`~tests.fixtures.common_fixtures.client_user` fixture.
:return:
A ``CncMap`` object that can be used to create :class:`kirovy.models.cnc_map.CncMapFile` objects in tests.
"""
+ if user_id is t.NO_VALUE:
+ user_id = client_user.kirovy_user.id
if not map_categories:
map_categories = [
cnc_map_category,
@@ -67,6 +76,8 @@ def _inner(
cnc_game=cnc_game,
description=description,
map_name=map_name,
+ is_legacy=is_legacy,
+ cnc_user_id=user_id,
)
cnc_map.save()
cnc_map.categories.add(*map_categories)
diff --git a/tests/test_views/test_ban_view.py b/tests/test_views/test_ban_view.py
new file mode 100644
index 0000000..7de8323
--- /dev/null
+++ b/tests/test_views/test_ban_view.py
@@ -0,0 +1,72 @@
+from rest_framework import status
+
+BASE_URL = "/admin/ban/"
+
+
+def test_ban_has_permission(client_moderator, create_cnc_map, create_kirovy_user):
+ user = create_kirovy_user(username="SethOfNOD")
+ cnc_map = create_cnc_map(user_id=user.id)
+ data = dict(
+ object_type="map",
+ is_banned=True,
+ object_id=str(cnc_map.id),
+ )
+
+ response = client_moderator.post(BASE_URL, data=data)
+ assert response.status_code == status.HTTP_200_OK
+ cnc_map.refresh_from_db()
+
+ assert cnc_map.is_banned
+
+ ## Ban the user
+ data = dict(
+ object_type="user",
+ is_banned=True,
+ object_id=str(user.id),
+ )
+
+ response = client_moderator.post(BASE_URL, data=data)
+ assert response.status_code == status.HTTP_200_OK
+ user.refresh_from_db()
+
+ assert user.is_banned
+
+
+def test_ban_404(client_moderator):
+ data = dict(
+ object_type="map",
+ is_banned=True,
+ object_id="02a666d6-ea58-46bd-85e7-7ac1d8754cf5", # if this test fails because of this istg
+ )
+ response = client_moderator.post(BASE_URL, data=data)
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+def test_ban_no_permission(client_user, create_kirovy_user, client_anonymous):
+ user_to_try_to_ban = create_kirovy_user()
+ data = dict(
+ object_type="user",
+ object_id=str(user_to_try_to_ban.id),
+ is_banned=True,
+ )
+ response = client_user.post(BASE_URL, data=data)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ ## attempt not logged in
+ response = client_anonymous.post(BASE_URL, data=data)
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_ban_cant_ban_legacy_maps(client_god, create_cnc_map, user, settings):
+ """Sacred artifacts of the old internet can't be banned over the API."""
+ sacred_artifact = create_cnc_map(user_id=user.id, is_legacy=True)
+ data = dict(
+ object_type="map",
+ object_id=str(sacred_artifact.id),
+ is_banned=True,
+ )
+ response = client_god.post(BASE_URL, data=data)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert response.data["message"] == "legacy-maps-cannot-be-banned"
diff --git a/tests/test_views/test_map_delete.py b/tests/test_views/test_map_delete.py
new file mode 100644
index 0000000..ed23d49
--- /dev/null
+++ b/tests/test_views/test_map_delete.py
@@ -0,0 +1,35 @@
+from django import urls
+from rest_framework import status
+
+BASE_URL = "/maps/delete/"
+
+
+def test_delete_own_map(client_user, create_cnc_map):
+ """Right now, only staff can delete maps."""
+ cnc_map = create_cnc_map(user_id=client_user.kirovy_user.id)
+
+ url = f"{BASE_URL}{cnc_map.id}/"
+
+ response = client_user.delete(url)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_delete_map__staff(client_moderator, create_cnc_map, user):
+ """Moderators should be able to delete maps."""
+ cnc_map = create_cnc_map(user_id=user.id)
+ url = f"{BASE_URL}{cnc_map.id}/"
+
+ response = client_moderator.delete(url)
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+
+def test_delete_map__legacy(client_god, create_cnc_map):
+ """No one can delete legacy maps over the API."""
+ cnc_map = create_cnc_map(user_id=None, is_legacy=True)
+ url = f"{BASE_URL}{cnc_map.id}/"
+
+ response = client_god.delete(url)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
diff --git a/tests/test_views/test_map_upload.py b/tests/test_views/test_map_upload.py
index faf305f..4142a5d 100644
--- a/tests/test_views/test_map_upload.py
+++ b/tests/test_views/test_map_upload.py
@@ -12,9 +12,7 @@
_UPLOAD_URL = "/maps/upload/"
-def test_map_file_upload_happy_path(
- client_user, file_map_desert, game_yuri, extension_map, tmp_media_root
-):
+def test_map_file_upload_happy_path(client_user, file_map_desert, game_yuri, extension_map, tmp_media_root):
response = client_user.post(
_UPLOAD_URL,
{"file": file_map_desert, "game_id": str(game_yuri.id)},
@@ -27,15 +25,16 @@ def test_map_file_upload_happy_path(
uploaded_file_url: str = response.data["result"]["cnc_map_file"]
uploaded_image_url: str = response.data["result"]["extracted_preview_file"]
strip_media_url = f"/{settings.MEDIA_URL}"
- uploaded_file = pathlib.Path(tmp_media_root) / uploaded_file_url.lstrip(
- strip_media_url
- )
- uploaded_image = pathlib.Path(tmp_media_root) / uploaded_image_url.lstrip(
- strip_media_url
- )
+ uploaded_file = pathlib.Path(tmp_media_root) / uploaded_file_url.lstrip(strip_media_url)
+ uploaded_image = pathlib.Path(tmp_media_root) / uploaded_image_url.lstrip(strip_media_url)
assert uploaded_file.exists()
assert uploaded_image.exists()
+ file_response = client_user.get(uploaded_file_url)
+ image_response = client_user.get(uploaded_image_url)
+ assert file_response.status_code == status.HTTP_200_OK
+ assert image_response.status_code == status.HTTP_200_OK
+
parser = CncGen2MapParser(UploadedFile(open(uploaded_file, "rb")))
assert parser.ini.get("CnCNet", "ID") == str(response.data["result"]["cnc_map_id"])
@@ -56,24 +55,14 @@ def test_map_file_upload_happy_path(
# A lot of these will break if you change the desert.map file.
assert response_map["cnc_user_id"] == str(client_user.kirovy_user.id)
- assert (
- response_map["map_name"] == "desert"
- ), "Should match the name in the map file."
+ assert response_map["map_name"] == "desert", "Should match the name in the map file."
assert response_map["cnc_game_id"] == str(game_yuri.id)
assert response_map["category_ids"] == [
str(MapCategory.objects.get(name__iexact="standard").id),
]
- assert not response_map[
- "is_published"
- ], "Newly uploaded, unrefined, maps should default to unpublished."
- assert not response_map[
- "is_temporary"
- ], "Maps uploaded via a signed in user shouldn't be marked as temporary."
+ assert not response_map["is_published"], "Newly uploaded, unrefined, maps should default to unpublished."
+ assert not response_map["is_temporary"], "Maps uploaded via a signed in user shouldn't be marked as temporary."
assert not response_map["is_reviewed"], "Maps should not default to being reviewed."
- assert not response_map[
- "is_banned"
- ], "Happy path maps should not be banned on upload."
- assert (
- response_map["legacy_upload_date"] is None
- ), "Non legacy maps should never have this field."
+ assert not response_map["is_banned"], "Happy path maps should not be banned on upload."
+ assert response_map["legacy_upload_date"] is None, "Non legacy maps should never have this field."
assert response_map["id"] == str(map_object.id)
diff --git a/tests/test_views/test_permission_views.py b/tests/test_views/test_permission_views.py
index 1559b4a..a3b673e 100644
--- a/tests/test_views/test_permission_views.py
+++ b/tests/test_views/test_permission_views.py
@@ -1,6 +1,6 @@
from rest_framework import status
-from kirovy.permissions import UiPermissions
+from kirovy.objects.ui_objects import UiPermissions
BASE_URL = "/ui-permissions/"
diff --git a/web_entry_point.sh b/web_entry_point.sh
new file mode 100644
index 0000000..6b030c0
--- /dev/null
+++ b/web_entry_point.sh
@@ -0,0 +1,5 @@
+# Don't run this file manually. It's the entry point for the dockerfile.
+python manage.py collectstatic --no-input
+python manage.py migrate
+# `python manage.py runserver`, but with gunicorn instead.
+gunicorn "kirovy.wsgi:application" --bind "0.0.0.0:8000"