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"