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 %}
+
+
+
+{% 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
+
+
+
+
+
+{% 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 %}
+
+ {% 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.
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+{% 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