diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..4b7d22a --- /dev/null +++ b/.env.dist @@ -0,0 +1,24 @@ +USER_ID=1000 # id user +GROUP_ID=1000 + +TRAEFIK_HOST_RULE="Host(`example.com`) || Host(`www.example.com`)" + +LISTEN_PORT=8000 # only for dev + +DEBUG=true +SECRET="change_me" # generate with "pwgen -y 50" +ALLOWED_HOSTS='["127.0.0.1", "localhost"]' +CSRF_TRUSTED_ORIGINS='["http://127.0.0.1:8000", "http://localhost:8000"]' +BASE_URL='http://127.0.0.1/' + +### DEV RELATED +# VITE +DEV_SERVER_HOST='127.0.0.1' +DEV_SERVER_PORT='8080' + +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend # django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=example.test # +EMAIL_PORT=587 # 587 +EMAIL_USER=test # +EMAIL_PASSWORD=test # +EMAIL_SENDER=no-reply@example.test diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ac21435 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..526a218 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..609c0f6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/oximg.iml b/.idea/oximg.iml new file mode 100644 index 0000000..0e25da0 --- /dev/null +++ b/.idea/oximg.iml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..6e95ae2 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,9 @@ +media/ +static_collected/ +*.pyc +__pycache__/ +.env +.git/ +venv/ +*.sqlite3 +frontend/node_modules \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..ed44ccb --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,72 @@ +FROM python:3.12-slim + +ARG puid=1000 +ARG pgid=1000 +ARG debug=false +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + DEBUG=${debug} + +# setup user +RUN groupadd -g ${pgid} -o custom_user && useradd -m -u ${puid} -g ${pgid} -o -s /bin/bash custom_user + +# setup system requirements +RUN apt-get update && apt-get install -y \ + gcc \ + libjpeg-dev \ + zlib1g-dev \ + curl \ + gnupg \ + ca-certificates \ + inotify-tools \ + libmagic1 \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ + && apt-get update && apt-get install nodejs -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g yarn + +# setup node requirements +WORKDIR /app/frontend +COPY ./frontend/package.json ./package.json +RUN yarn install +RUN if [ "$debug" = "true" ] ; then \ + echo "Mode dev, pas de build frontend" ; \ + else \ + yarn build ; \ + fi +RUN chown -R custom_user:custom_user /app/frontend/node_modules + +# setup python requirements +WORKDIR /app +COPY requirements/ requirements/ +RUN if [ "$debug" = "true" ] ; then \ + pip install --no-cache-dir -r requirements/dev.txt ; \ + else \ + pip install --no-cache-dir -r requirements/prod.txt ; \ + fi + +COPY . . + +# collect static +RUN if [ "$debug" = "true" ] ; then \ + echo "Mode dev, pas de collectstatic" ; \ + else \ + python manage.py collectstatic --noinput ; \ + fi + +USER custom_user +# setup ipython +RUN mkdir -p ~/.ipython/profile_default/ && echo "c.InteractiveShellApp.extensions = ['autoreload']\nc.InteractiveShellApp.exec_lines = ['%autoreload 2']" > ~/.ipython/profile_default/ipython_config.py + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD \ + pgrep hypercorn >/dev/null 2>&1 && curl -f http://127.0.0.1:8000/health/ || exit 0 + +EXPOSE 8000 + +# Utiliser Hypercorn pour lancer l'app ASGI +CMD ["hypercorn", "monprojet.asgi:application", "--bind", "0.0.0.0:8000"] diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/apps.py b/app/api/apps.py new file mode 100644 index 0000000..66656fd --- /dev/null +++ b/app/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/app/api/autoload.py b/app/api/autoload.py new file mode 100644 index 0000000..5674721 --- /dev/null +++ b/app/api/autoload.py @@ -0,0 +1,19 @@ +from django.conf import settings + +import importlib + + +def load_viewset_registries(router): + """ + Parcourt toutes les apps installées et appelle leur register_viewsets(router) si présent. + """ + for app in settings.INSTALLED_APPS: + module_path = f"{app}.api.registry" + try: + module = importlib.import_module(module_path) + if hasattr(module, "register_viewsets"): + module.register_viewsets(router) + except ModuleNotFoundError: + continue + except AttributeError as e: + print(f"[ERROR] Could not import {module_path}: {e}") diff --git a/app/api/routers.py b/app/api/routers.py new file mode 100644 index 0000000..fc89141 --- /dev/null +++ b/app/api/routers.py @@ -0,0 +1,4 @@ +from rest_framework import routers + + +router = routers.DefaultRouter() diff --git a/app/api/tests.py b/app/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/api/urls.py b/app/api/urls.py new file mode 100644 index 0000000..5a93802 --- /dev/null +++ b/app/api/urls.py @@ -0,0 +1,20 @@ +from django.urls import path, include + +from rest_framework.authtoken import views + +from .routers import router +from .autoload import load_viewset_registries + +load_viewset_registries(router) + +app_name = 'api' +urlpatterns = [ + # Endpoints d'API REST + path('', include(router.urls)), + + # Endpoint d'authentification par token + path('auth-token/', views.obtain_auth_token, name='api-token-auth'), + + # Endpoints d'authentification de l'API browsable + path('auth/', include('rest_framework.urls', namespace='rest_framework')), +] diff --git a/app/api/utils.py b/app/api/utils.py new file mode 100644 index 0000000..614a1e6 --- /dev/null +++ b/app/api/utils.py @@ -0,0 +1,14 @@ +import inspect + + +def register_in_app(router, prefix, viewset, basename=None): + # Trouve le module appelant pour déduire le nom de l'app + caller = inspect.stack()[1] + module = inspect.getmodule(caller.frame) + module_parts = module.__name__.split('.') + app_label = module_parts[0] # ex: "my_app" + + full_prefix = f"{app_label}/{prefix.strip('/')}" + if basename is None: + basename = f"{app_label}-{prefix}" + router.register(full_prefix, viewset, basename=basename) diff --git a/app/app/__init__.py b/app/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app/asgi.py b/app/app/asgi.py new file mode 100644 index 0000000..0cb7099 --- /dev/null +++ b/app/app/asgi.py @@ -0,0 +1,17 @@ +""" +ASGI config for app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os +from django.core.asgi import get_asgi_application +from asgiref.compatibility import guarantee_single_callable + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +django_application = get_asgi_application() +application = guarantee_single_callable(django_application) diff --git a/app/app/settings.py b/app/app/settings.py new file mode 100644 index 0000000..cabeead --- /dev/null +++ b/app/app/settings.py @@ -0,0 +1,179 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from os import getenv +import ast + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = getenv("SECRET", "random_need_to_be_generated") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = getenv("DEBUG", "false") == "true" + +ALLOWED_HOSTS = ["app", "127.0.0.1", "localhost"] +if getenv("ALLOWED_HOSTS"): + ALLOWED_HOSTS += ast.literal_eval(getenv("ALLOWED_HOSTS")) + +CSRF_TRUSTED_ORIGINS = [] +for allowed_host in ALLOWED_HOSTS: + CSRF_TRUSTED_ORIGINS += ast.literal_eval(getenv("CSRF_TRUSTED_ORIGINS")) + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + "django_vite", + "rest_framework", + + 'api', + 'user', + 'upload', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] +if DEBUG: + MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"] + MIDDLEWARE + +ROOT_URLCONF = 'app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / "templates"], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'app.wsgi.application' +ASGI_APPLICATION = "app.asgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "static_collected" +STATICFILES_DIRS = [ + BASE_DIR / "static", + BASE_DIR / "frontend/dist", +] + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# User related +AUTH_USER_MODEL = "user.User" +LOGIN_REDIRECT_URL = "/" +LOGIN_URL = "/user/login/" +LOGOUT_REDIRECT_URL = "/user/login/" +SESSION_COOKIE_AGE = 30*24*60*60 # 30 days + +# Django-vite related +# https://github.com/MrBin99/django-vite/tree/master +DJANGO_VITE = { + "default": { + "dev_mode": DEBUG, + "manifest_path": BASE_DIR / "frontend/dist/manifest.json", + "dev_server_host": getenv("DEV_SERVER_HOST", "localhost"), + "dev_server_port": int(getenv("DEV_SERVER_PORT", 8080)), + } +} + +# Email related +EMAIL_BACKEND = getenv("EMAIL_BACKEND", 'django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = getenv("EMAIL_HOST", None) +EMAIL_PORT = getenv("EMAIL_PORT", None) +EMAIL_HOST_USER = getenv("EMAIL_USER", None) +EMAIL_HOST_PASSWORD = getenv("EMAIL_PASSWORD", None) +EMAIL_SENDER = getenv("EMAIL_SENDER", None) +EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = EMAIL_SENDER +EMAIL_SUBJECT_PREFIX = getenv("EMAIL_SUBJECT_PREFIX", None) +EMAIL_ADMIN = getenv("EMAIL_ADMIN", None) diff --git a/app/app/urls.py b/app/app/urls.py new file mode 100644 index 0000000..7cf37e4 --- /dev/null +++ b/app/app/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for app project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.http import HttpResponse + +urlpatterns = [ + path("health/", lambda request: HttpResponse("OK")), + path('admin/', admin.site.urls), + path('api/', include('api.urls', namespace='api')), + path('user/', include('user.urls', namespace='user')), +] diff --git a/app/app/wsgi.py b/app/app/wsgi.py new file mode 100644 index 0000000..121dd78 --- /dev/null +++ b/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_wsgi_application() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/admin.py b/app/core/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/core/apps.py b/app/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/app/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/app/core/migrations/__init__.py b/app/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/app/core/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/app/core/templates/core/index.html b/app/core/templates/core/index.html new file mode 100644 index 0000000..9142dac --- /dev/null +++ b/app/core/templates/core/index.html @@ -0,0 +1,2 @@ +{% extends "core/layout.html" %} + diff --git a/app/core/templates/core/layout.html b/app/core/templates/core/layout.html new file mode 100644 index 0000000..e69de29 diff --git a/app/core/tests.py b/app/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/core/urls.py b/app/core/urls.py new file mode 100644 index 0000000..0150d4f --- /dev/null +++ b/app/core/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +urlspatterns = [ + +] \ No newline at end of file diff --git a/app/core/views.py b/app/core/views.py new file mode 100644 index 0000000..e0bcbc6 --- /dev/null +++ b/app/core/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class IndexView(TemplateView): + template_name = "core/index.html" diff --git a/app/db.sqlite3 b/app/db.sqlite3 new file mode 100644 index 0000000..29692a7 Binary files /dev/null and b/app/db.sqlite3 differ diff --git a/app/dev_run.sh b/app/dev_run.sh new file mode 100644 index 0000000..538a4c5 --- /dev/null +++ b/app/dev_run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +CORES=$(nproc) +WORKERS=$((2 * CORES + 1)) + +cd /app/frontend && yarn dev & +#cd /app && python manage.py runserver 0.0.0.0:8000 & +cd /app && hypercorn app.asgi:application -c hypercorn_dev.toml & + +wait -n + +exit $? \ No newline at end of file diff --git a/app/frontend/jsconfig.json b/app/frontend/jsconfig.json new file mode 100644 index 0000000..babf8fb --- /dev/null +++ b/app/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} \ No newline at end of file diff --git a/app/frontend/package.json b/app/frontend/package.json new file mode 100644 index 0000000..a98a110 --- /dev/null +++ b/app/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@mdi/font": "^7.4.47", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.2", + "postcss-import": "^16.1.0", + "postcss-simple-vars": "^7.0.1", + "vite": "^6.1.0" + }, + "dependencies": { + "events": "^3.3.0", + "moment": "^2.30.1" + } +} diff --git a/app/frontend/postcss.config.js b/app/frontend/postcss.config.js new file mode 100644 index 0000000..058a505 --- /dev/null +++ b/app/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + 'postcss-import': {}, + 'postcss-simple-vars': {}, + 'autoprefixer': {} + } +} \ No newline at end of file diff --git a/app/frontend/vite.config.js b/app/frontend/vite.config.js new file mode 100644 index 0000000..e5dc52e --- /dev/null +++ b/app/frontend/vite.config.js @@ -0,0 +1,44 @@ +import {defineConfig, loadEnv} from "vite"; +import {resolve, join} from "path"; + +export default defineConfig((mode) => { + const env = loadEnv(mode, "..", ""), + SRC_DIR = resolve("./src"), + OUT_DIR = resolve("./dist") + + return { + plugins: [ + + ], + resolve: { + alias: { + "@": resolve(SRC_DIR), + "vue": "vue/dist/vue.esm-bundler.js" + } + }, + root: SRC_DIR, + base: "/static/", + css: { + postcss: "./postcss.config.js" + }, + server: { + host: "0.0.0.0", + port: env.DEV_SERVER_PORT, + origin: `http://${env.DEV_SERVER_HOST}:${env.DEV_SERVER_PORT}`, // hotfix, webfont was loaded from wrong url + watch: { + usePolling: true, + reloadDelay: 500, + } + }, + build: { + manifest: "manifest.json", + emptyOutDir: true, + outDir: OUT_DIR, + rollupOptions: { + input: { + // admin: join(SRC_DIR, "app/admin-entrypoint.js"), + } + } + } + } +}) \ No newline at end of file diff --git a/app/hypercorn.toml b/app/hypercorn.toml new file mode 100644 index 0000000..62ff635 --- /dev/null +++ b/app/hypercorn.toml @@ -0,0 +1,4 @@ +bind = ["0.0.0.0:8000"] +loop = "uvloop" +worker_class = "asyncio" # asyncio ou trio +workers = 4 \ No newline at end of file diff --git a/app/hypercorn_dev.toml b/app/hypercorn_dev.toml new file mode 100644 index 0000000..8e50005 --- /dev/null +++ b/app/hypercorn_dev.toml @@ -0,0 +1,9 @@ +bind = ["0.0.0.0:8000"] +loop = "uvloop" +worker_class = "asyncio" # asyncio ou trio +workers = 4 +debug = true +reload = true +accesslog = "-" +errorlog = "-" +loglevel = "INFO" diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..4931389 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/app/requirements/common.txt b/app/requirements/common.txt new file mode 100644 index 0000000..a55e97e --- /dev/null +++ b/app/requirements/common.txt @@ -0,0 +1,9 @@ +Django +django-vite +Hypercorn[uvloop] +uvloop +python-magic +nanoid +djangorestframework +markdown +django-filter \ No newline at end of file diff --git a/app/requirements/dev.txt b/app/requirements/dev.txt new file mode 100644 index 0000000..48c5fb4 --- /dev/null +++ b/app/requirements/dev.txt @@ -0,0 +1,3 @@ +-r common.txt +whitenoise +django-stubs # temporaire le temps que pycharm sorte la version 2025.1.1 diff --git a/app/requirements/prod.txt b/app/requirements/prod.txt new file mode 100644 index 0000000..c3899b0 --- /dev/null +++ b/app/requirements/prod.txt @@ -0,0 +1 @@ +-r common.txt \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..90ef1f9 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {% block base_title %}{% endblock %} + {% load django_vite %} + {% vite_hmr_client %} + + {% block extra_head %}{% endblock %} + + + +
+ {% block content %}{% endblock %} + {% vite_asset 'main.js' %} +
+ + diff --git a/app/upload/__init__.py b/app/upload/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/upload/admin.py b/app/upload/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/upload/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/upload/api/serializers.py b/app/upload/api/serializers.py new file mode 100644 index 0000000..936193e --- /dev/null +++ b/app/upload/api/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from upload.models import Upload + + +class UploadSerializer(serializers.ModelSerializer): + class Meta: + model = Upload + fields = ['id', 'created_at', 'user', 'filename', 'slug'] + read_only_fields = ['id', 'created_at', 'slug'] \ No newline at end of file diff --git a/app/upload/api/viewsets.py b/app/upload/api/viewsets.py new file mode 100644 index 0000000..fdf3fcf --- /dev/null +++ b/app/upload/api/viewsets.py @@ -0,0 +1,19 @@ +from rest_framework import viewsets, permissions + +from upload.models import Upload +from upload.api.serializers import UploadSerializer + +class UploadViewSet(viewsets.ModelViewSet): + queryset = Upload.objects.all() + serializer_class = UploadSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + # Les utilisateurs ne peuvent voir que leurs propres uploads + if not self.request.user.is_staff: + return Upload.objects.filter(user=self.request.user) + # Les administrateurs peuvent voir tous les uploads + return Upload.objects.all() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) \ No newline at end of file diff --git a/app/upload/apps.py b/app/upload/apps.py new file mode 100644 index 0000000..2dcd356 --- /dev/null +++ b/app/upload/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UploadConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'upload' diff --git a/app/upload/forms.py b/app/upload/forms.py new file mode 100644 index 0000000..97b8769 --- /dev/null +++ b/app/upload/forms.py @@ -0,0 +1,26 @@ +from django import forms + +from .models import Upload, Image + + +class UploadImageForm(forms.ModelForm): + class Meta: + model = Image + fields = ['file'] + + def save(self, commit=True): + image = super().save(commit=False) + + if not image.name: + image.name = image.file.name + + if not image.content_type: + from utils import get_mimetype + image.content_type = get_mimetype(image.file) + + image.size = image.file.size + + if commit: + image.save() + + return image diff --git a/app/upload/migrations/0001_initial.py b/app/upload/migrations/0001_initial.py new file mode 100644 index 0000000..09d1223 --- /dev/null +++ b/app/upload/migrations/0001_initial.py @@ -0,0 +1,148 @@ +# Generated by Django 5.2 on 2025-05-04 14:43 + +import django.db.models.deletion +import upload.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Archive', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Audio', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Code', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Other', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StructuredData', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Text', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Video', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=upload.models.upload_to)), + ('content_type', models.CharField(max_length=255)), + ('size', models.PositiveBigIntegerField()), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Upload', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('filename', models.CharField(max_length=255)), + ('object_id', models.UUIDField(blank=True, null=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/app/upload/migrations/__init__.py b/app/upload/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/upload/models.py b/app/upload/models.py new file mode 100644 index 0000000..aee6b3c --- /dev/null +++ b/app/upload/models.py @@ -0,0 +1,172 @@ +from django.utils import timezone +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +import uuid +from pathlib import Path +from nanoid import generate + + +def generate_unique_slug_for_upload(size=10): + while True: + slug = generate(size=size) + if not Upload.objects.filter(slug=slug).exists(): + return slug + + + +def upload_to(instance, filename): + today = timezone.now().date() + dest_path = Path(f"uploads/{str(today.year)}/{str(today.month)}/{str(today.day)}") + if not dest_path.exists(): + dest_path.mkdir(parents=True) + + uid = uuid.uuid4().hex + file_path = Path(filename) + return str(dest_path / f"{uid}{file_path.suffix}") + + +class Upload(models.Model): + id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4) + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey('user.User', on_delete=models.CASCADE, null=True, blank=True) + filename = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True, default=generate_unique_slug_for_upload, editable=False) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.UUIDField(null=True, blank=True) + content_object = GenericForeignKey('content_type', 'object_id') + + +class AbstractFile(models.Model): + file = models.FileField(upload_to=upload_to) + content_type = models.CharField(max_length=255) + size = models.PositiveBigIntegerField() + name = models.CharField(max_length=255) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + if self.file: + from utils import get_mimetype + self.size = self.file.size + self.content_type = get_mimetype(self.file) + self.name = self.file.name + + +class Image(AbstractFile): + allowed_mimetypes = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", + "image/svg+xml", + "image/tiff", + "image/x-icon", # favicon + "image/vnd.microsoft.icon" + ] + pass + +class Video(AbstractFile): + allowed_mimetypes = [ + "video/mp4", + "video/quicktime", # .mov + "video/x-msvideo", # .avi + "video/x-matroska", # .mkv + "video/webm", + "video/mpeg", + "video/ogg" + ] + pass + +class Audio(AbstractFile): + allowed_mimetypes = [ + "audio/mpeg", # .mp3 + "audio/wav", # .wav + "audio/x-wav", + "audio/ogg", # .ogg + "audio/webm", # .webm audio + "audio/aac", + "audio/flac", + "audio/x-flac" + ] + pass + +class Document(AbstractFile): + allowed_mimetypes = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", # .pptx + "application/rtf", + "text/plain", + "text/csv" + ] + pass + +class Archive(AbstractFile): + allowed_mimetypes = [ + "application/zip", + "application/x-tar", + "application/x-gzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-rar-compressed" + ] + +class Text(AbstractFile): + allowed_mimetypes = [ + "text/plain", # .txt, .log + ] + pass + + +class Code(AbstractFile): + allowed_mimetypes = [ + "text/x-python", # .py + "text/x-shellscript", # .sh, .bash + "text/x-csrc", # .c + "text/x-c++src", # .cpp, .cc + "text/x-java-source", # .java + "text/x-go", # .go + "text/x-rustsrc", # .rs + "text/x-sql", # .sql + "text/x-markdown", # .md + "text/markdown", # .md (alternative) + "text/x-makefile", # Makefile + "text/x-php", # .php + "application/javascript", # .js (modern) + "text/javascript", # .js (legacy) + "text/css", # .css + "application/x-perl", # .pl + "application/x-ruby", # .rb + "application/x-lua", # .lua + ] + + +class StructuredData(AbstractFile): + allowed_mimetypes = [ + "application/json", # .json + "application/xml", # .xml + "text/xml", # .xml alternative + "text/csv", # .csv + "text/tab-separated-values", # .tsv + "application/x-yaml", # .yaml (rare) + "text/x-yaml", # .yaml, .yml (le plus courant) + "application/vnd.ms-excel", # .xls (Excel legacy) + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx + "application/x-hdf", # .hdf, .h5 + "application/x-parquet", # .parquet + "application/x-ndjson", # .ndjson (newline-delimited JSON) + "application/x-netcdf", # .nc + ] + +class Other(AbstractFile): + allowed_extensions = [] + pass diff --git a/app/upload/templates/upload/layout.html b/app/upload/templates/upload/layout.html new file mode 100644 index 0000000..63913c1 --- /dev/null +++ b/app/upload/templates/upload/layout.html @@ -0,0 +1 @@ +{% extends "base.html" %} \ No newline at end of file diff --git a/app/upload/templates/upload/upload_form.html b/app/upload/templates/upload/upload_form.html new file mode 100644 index 0000000..c964fdf --- /dev/null +++ b/app/upload/templates/upload/upload_form.html @@ -0,0 +1,2 @@ +{% extends "upload/layout.html" %} + diff --git a/app/upload/tests.py b/app/upload/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/upload/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/upload/utils.py b/app/upload/utils.py new file mode 100644 index 0000000..50f2d56 --- /dev/null +++ b/app/upload/utils.py @@ -0,0 +1,26 @@ +import magic +from upload.models import AbstractFile +from functools import lru_cache + +@lru_cache(maxsize=128) +def get_mimetype_class_map(): + mapping = {} + for cls in AbstractFile.__subclasses__(): + for mt in getattr(cls, 'allowed_mimetypes', []): + mapping[mt] = cls + return mapping + + +def get_mimetype(file_obj): + mimetype = magic.from_buffer(file_obj.read(2048), mime=True) + file_obj.seek(0) + return mimetype + + +def get_upload_class_for_mimetype(mimetype): + mimetype = mimetype.lower().replace('x-', '') # normalisation légère + return get_mimetype_class_map().get(mimetype) + + +def create_upload(request, file_obj): + pass diff --git a/app/upload/views.py b/app/upload/views.py new file mode 100644 index 0000000..627b90c --- /dev/null +++ b/app/upload/views.py @@ -0,0 +1,25 @@ +from django.shortcuts import render, redirect +from .forms import UploadImageForm +from .models import Upload +from django.contrib.contenttypes.models import ContentType + + +def upload_image_view(request): + if request.method == 'POST': + form = UploadImageForm(request.POST, request.FILES) + if form.is_valid(): + image = form.save() + + upload = Upload.objects.create( + filename=image.file.name, + content_type=ContentType.objects.get_for_model(image), + object_id=image.id, + user=request.user if request.user.is_authenticated else None + ) + + return redirect('upload_success', slug=upload.slug) + + else: + form = UploadImageForm() + + return render(request, 'upload/upload_form.html', {'form': form}) \ No newline at end of file diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/admin.py b/app/user/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/user/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/user/api/registry.py b/app/user/api/registry.py new file mode 100644 index 0000000..78b6e28 --- /dev/null +++ b/app/user/api/registry.py @@ -0,0 +1,6 @@ +from api.utils import register_in_app +from .viewsets import UserViewSet + + +def register_viewsets(router): + register_in_app(router, 'users', UserViewSet) diff --git a/app/user/api/serializers.py b/app/user/api/serializers.py new file mode 100644 index 0000000..53bf4e2 --- /dev/null +++ b/app/user/api/serializers.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from user.models import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'max_upload_size'] + read_only_fields = ['id', 'max_upload_size'] + extra_kwargs = {'password': {'write_only': True}} diff --git a/app/user/api/viewsets.py b/app/user/api/viewsets.py new file mode 100644 index 0000000..2f2410e --- /dev/null +++ b/app/user/api/viewsets.py @@ -0,0 +1,17 @@ +from rest_framework import viewsets, permissions + +from user.models import User +from user.api.serializers import UserSerializer + + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + # Les utilisateurs normaux ne peuvent voir que leur propre profil + if not self.request.user.is_staff: + return User.objects.filter(id=self.request.user.id) + # Les administrateurs peuvent voir tous les utilisateurs + return User.objects.all() \ No newline at end of file diff --git a/app/user/apps.py b/app/user/apps.py new file mode 100644 index 0000000..36cce4c --- /dev/null +++ b/app/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/app/user/forms.py b/app/user/forms.py new file mode 100644 index 0000000..f1b2746 --- /dev/null +++ b/app/user/forms.py @@ -0,0 +1,17 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm +from .models import User + +class UserRegistrationForm(UserCreationForm): + email = forms.EmailField(required=True, help_text="Requis. Entrez une adresse email valide.") + + class Meta: + model = User + fields = ('username', 'email', 'password1', 'password2') + + def save(self, commit=True): + user = super().save(commit=False) + user.email = self.cleaned_data['email'] + if commit: + user.save() + return user \ No newline at end of file diff --git a/app/user/migrations/0001_initial.py b/app/user/migrations/0001_initial.py new file mode 100644 index 0000000..f6d8d8d --- /dev/null +++ b/app/user/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2 on 2025-04-27 00:53 + +import django.contrib.auth.validators +import django.utils.timezone +import user.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('max_upload_size', models.PositiveBigIntegerField(default=1073741824)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', user.models.UsernameUserManager()), + ], + ), + ] diff --git a/app/user/migrations/0002_alter_user_max_upload_size.py b/app/user/migrations/0002_alter_user_max_upload_size.py new file mode 100644 index 0000000..89fc4c3 --- /dev/null +++ b/app/user/migrations/0002_alter_user_max_upload_size.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-04 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='max_upload_size', + field=models.PositiveBigIntegerField(default=104857600), + ), + ] diff --git a/app/user/migrations/__init__.py b/app/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/models.py b/app/user/models.py new file mode 100644 index 0000000..44284dc --- /dev/null +++ b/app/user/models.py @@ -0,0 +1,43 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser, BaseUserManager + + +class UsernameUserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, username, email, password, **extra_fields): + if not username: + raise ValueError("Un username doit être défini") + if not email: + raise ValueError("Un email doit être défini") + + email = self.normalize_email(email) + user = self.model(username=username, email=email, **extra_fields) + user.set_password(password) + user.save(using=self.db) + return user + + def create_user(self, username, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(username, email, password, **extra_fields) + + def create_superuser(self, username, email, password, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser doit être staff à True") + if extra_fields.get("is_superuser") is not True: + raise ValueError("SuperUser doit être is_superuser à True") + + return self._create_user(username, email, password, **extra_fields) + + +class User(AbstractUser): + email = models.EmailField(unique=True) + + max_upload_size = models.PositiveBigIntegerField(default=100*1024*1024) # 100Mo + + objects = UsernameUserManager() + diff --git a/app/user/templates/user/login.html b/app/user/templates/user/login.html new file mode 100644 index 0000000..a1fd642 --- /dev/null +++ b/app/user/templates/user/login.html @@ -0,0 +1,135 @@ +{% extends 'base.html' %} + +{% block base_title %}Connexion{% endblock %} + +{% block content %} +
+

Connexion

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + + +
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_change_done.html b/app/user/templates/user/password_change_done.html new file mode 100644 index 0000000..cfa8dc0 --- /dev/null +++ b/app/user/templates/user/password_change_done.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} + +{% block base_title %}Mot de passe modifié{% endblock %} + +{% block content %} +
+

Mot de passe modifié

+ +
+

Votre mot de passe a été modifié avec succès.

+
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_change_form.html b/app/user/templates/user/password_change_form.html new file mode 100644 index 0000000..517fd9d --- /dev/null +++ b/app/user/templates/user/password_change_form.html @@ -0,0 +1,125 @@ +{% extends 'base.html' %} + +{% block base_title %}Changer le mot de passe{% endblock %} + +{% block content %} +
+

Changer le mot de passe

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} +
+ {% endfor %} + + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_complete.html b/app/user/templates/user/password_reset_complete.html new file mode 100644 index 0000000..bab700e --- /dev/null +++ b/app/user/templates/user/password_reset_complete.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} + +{% block base_title %}Mot de passe réinitialisé{% endblock %} + +{% block content %} +
+

Mot de passe réinitialisé

+ +
+

Votre mot de passe a été réinitialisé avec succès.

+
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_confirm.html b/app/user/templates/user/password_reset_confirm.html new file mode 100644 index 0000000..998b29f --- /dev/null +++ b/app/user/templates/user/password_reset_confirm.html @@ -0,0 +1,149 @@ +{% extends 'base.html' %} + +{% block base_title %}Définir un nouveau mot de passe{% endblock %} + +{% block content %} +
+

Définir un nouveau mot de passe

+ + {% if validlink %} +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} +
+ {% endfor %} + + +
+ {% else %} +
+

Le lien de réinitialisation du mot de passe n'est pas valide, probablement parce qu'il a déjà été utilisé.

+

Veuillez demander une nouvelle réinitialisation de mot de passe.

+
+ + + {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_done.html b/app/user/templates/user/password_reset_done.html new file mode 100644 index 0000000..88ffd9f --- /dev/null +++ b/app/user/templates/user/password_reset_done.html @@ -0,0 +1,88 @@ +{% extends 'base.html' %} + +{% block base_title %}Email de réinitialisation envoyé{% endblock %} + +{% block content %} +
+

Email de réinitialisation envoyé

+ +
+

Nous vous avons envoyé par email les instructions pour réinitialiser votre mot de passe.

+

Si vous ne recevez pas d'email, vérifiez que vous avez saisi l'adresse avec laquelle vous vous êtes inscrit, et vérifiez votre dossier de spam.

+
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_email.html b/app/user/templates/user/password_reset_email.html new file mode 100644 index 0000000..2c140c8 --- /dev/null +++ b/app/user/templates/user/password_reset_email.html @@ -0,0 +1,14 @@ +{% autoescape off %} +Bonjour, + +Vous recevez cet email car vous avez demandé la réinitialisation du mot de passe de votre compte sur {{ site_name }}. + +Veuillez suivre le lien ci-dessous pour définir un nouveau mot de passe : +{{ protocol }}://{{ domain }}{% url 'user:password_reset_confirm' uidb64=uid token=token %} + +Votre nom d'utilisateur, au cas où vous l'auriez oublié : {{ user.get_username }} + +Merci d'utiliser notre site ! + +L'équipe {{ site_name }} +{% endautoescape %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_form.html b/app/user/templates/user/password_reset_form.html new file mode 100644 index 0000000..eb535e5 --- /dev/null +++ b/app/user/templates/user/password_reset_form.html @@ -0,0 +1,151 @@ +{% extends 'base.html' %} + +{% block base_title %}Réinitialisation du mot de passe{% endblock %} + +{% block content %} +
+

Réinitialisation du mot de passe

+ +

Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} +
+ {% endfor %} + + +
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/password_reset_subject.txt b/app/user/templates/user/password_reset_subject.txt new file mode 100644 index 0000000..31f2961 --- /dev/null +++ b/app/user/templates/user/password_reset_subject.txt @@ -0,0 +1 @@ +Réinitialisation de votre mot de passe sur {{ site_name }} \ No newline at end of file diff --git a/app/user/templates/user/register.html b/app/user/templates/user/register.html new file mode 100644 index 0000000..193c63e --- /dev/null +++ b/app/user/templates/user/register.html @@ -0,0 +1,143 @@ +{% extends 'base.html' %} + +{% block base_title %}Inscription{% endblock %} + +{% block content %} +
+

Inscription

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} +
+ {% endfor %} + + +
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/user/tests.py b/app/user/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/user/urls.py b/app/user/urls.py new file mode 100644 index 0000000..467176c --- /dev/null +++ b/app/user/urls.py @@ -0,0 +1,41 @@ +from django.urls import path +from django.contrib.auth import views as auth_views +from . import views + +app_name = 'user' + +urlpatterns = [ + # Registration + path('register/', views.register, name='register'), + + # Login and Logout + path('login/', auth_views.LoginView.as_view(template_name='user/login.html'), name='login'), + path('logout/', auth_views.LogoutView.as_view(), name='logout'), + + # Change password + path('password_change/', auth_views.PasswordChangeView.as_view( + template_name='user/password_change_form.html', + success_url='/user/password_change/done/' + ), name='password_change'), + path('password_change/done/', auth_views.PasswordChangeDoneView.as_view( + template_name='user/password_change_done.html' + ), name='password_change_done'), + + # Reset password + path('password_reset/', auth_views.PasswordResetView.as_view( + template_name='user/password_reset_form.html', + email_template_name='user/password_reset_email.html', + subject_template_name='user/password_reset_subject.txt', + success_url='/user/password_reset/done/' + ), name='password_reset'), + path('password_reset/done/', auth_views.PasswordResetDoneView.as_view( + template_name='user/password_reset_done.html' + ), name='password_reset_done'), + path('reset///', auth_views.PasswordResetConfirmView.as_view( + template_name='user/password_reset_confirm.html', + success_url='/user/reset/done/' + ), name='password_reset_confirm'), + path('reset/done/', auth_views.PasswordResetCompleteView.as_view( + template_name='user/password_reset_complete.html' + ), name='password_reset_complete'), +] \ No newline at end of file diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..0605d04 --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,18 @@ +from django.shortcuts import render, redirect +from django.contrib.auth import login +from django.contrib import messages +from .models import User +from .forms import UserRegistrationForm + +def register(request): + if request.method == 'POST': + form = UserRegistrationForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, "Inscription réussie ! Vous êtes maintenant connecté.") + return redirect('/') + else: + form = UserRegistrationForm() + + return render(request, 'user/register.html', {'form': form}) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d995c3a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,15 @@ +services: + web: + restart: "no" + ports: + - "${LISTEN_PORT:-8000}:80" + volumes: + - ./nginx/dev.nginx:/etc/nginx/nginx.conf:ro + + app: + volumes: + - ./app:/app + restart: "no" + command: > + sh -c "python manage.py migrate --noinput && + ./dev_run.sh" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8883f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +networks: + web: + external: true + +volumes: + static_volume: + +services: + web: + image: nginx:alpine + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.docker.network=web" + # Routeur HTTP (port 80) pour la redirection vers HTTPS + - "traefik.http.routers.web-http.rule=${TRAEFIK_HOST_RULE}" + - "traefik.http.routers.web-http.entrypoints=web" + - "traefik.http.routers.web-http.middlewares=redirect-to-https" + + # Middleware de redirection + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + + # Routeur HTTPS + - "traefik.http.routers.web.rule=${TRAEFIK_HOST_RULE}" + - "traefik.http.routers.web.entrypoints=websecure" + - "traefik.http.routers.web.tls.certresolver=le" + + # Port du container cible (le port d'écoute de Nginx à l’intérieur du conteneur) + - "traefik.http.services.web.loadbalancer.server.port=80" + volumes: + - ./nginx/default.nginx:/etc/nginx/nginx.conf:ro + - static_volume:/static:ro + - ./app/media:/media:ro + depends_on: + - app + + app: + build: + context: ./app + dockerfile: "Dockerfile" + args: + puid: ${USER_ID} + pgid: ${GROUP_ID} + debug: ${DEBUG:-false} + restart: unless-stopped + healthcheck: + test: [ "CMD", "curl", "-f", "http://127.0.0.1:8000/health/" ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + env_file: + - .env + volumes: + - ./media:/app/media + - static_volume:/app/static_collected + - /app/frontend/node_modules + command: > + sh -c "python manage.py migrate --noinput && + hypercorn app.asgi:application -c hypercorn.toml" \ No newline at end of file diff --git a/nginx/default.nginx b/nginx/default.nginx new file mode 100644 index 0000000..1c7d1b0 --- /dev/null +++ b/nginx/default.nginx @@ -0,0 +1,29 @@ +events { + worker_connections 1024; +} + +http { + client_max_body_size 100M; + server { + listen 80; + + location /static/ { + alias /static/; + autoindex on; + } + + location /serve_media/ { + alias /media/; + autoindex on; + } + + location / { + proxy_redirect off; + proxy_set_header Host $http_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; + proxy_pass http://app:8000; + } + } +} diff --git a/nginx/dev.nginx b/nginx/dev.nginx new file mode 100644 index 0000000..847797c --- /dev/null +++ b/nginx/dev.nginx @@ -0,0 +1,25 @@ +events { + worker_connections 1024; +} + + +http { + client_max_body_size 100M; + server { + listen 80; + + location /serve_media/ { + alias /media/; + autoindex on; + } + + location / { + proxy_redirect off; + proxy_set_header Host $http_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; + proxy_pass http://app:8000; + } + } +} \ No newline at end of file