From 09ff6dd430b62039155887d70fa414b556ea000b Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Fri, 2 Oct 2026 15:54:40 +0200 Subject: [PATCH] init --- .env.example | 23 ++ .gitignore | 17 + README.md | 127 ++++++++ backend/Dockerfile | 9 + backend/core/__init__.py | 0 backend/core/apps.py | 4 + backend/core/management/__init__.py | 0 backend/core/management/commands/__init__.py | 0 .../core/management/commands/ensure_admin.py | 16 + .../core/management/commands/wait_for_db.py | 14 + backend/core/migrations/0001_initial.py | 35 +++ backend/core/migrations/__init__.py | 0 backend/core/models.py | 34 ++ backend/core/notifications.py | 16 + backend/core/serializers.py | 44 +++ backend/core/urls.py | 14 + backend/core/views.py | 98 ++++++ backend/entrypoint.sh | 6 + backend/manage.py | 6 + backend/planning/__init__.py | 2 + backend/planning/settings.py | 72 +++++ backend/planning/urls.py | 8 + backend/planning/wsgi.py | 4 + backend/requirements.txt | 7 + docker-compose.yml | 19 ++ frontend/Dockerfile | 11 + frontend/index.html | 13 + frontend/nginx.conf | 25 ++ frontend/package.json | 25 ++ frontend/src/App.jsx | 64 ++++ frontend/src/api/client.js | 23 ++ frontend/src/api/index.js | 19 ++ frontend/src/components/ScheduleWeek.jsx | 181 +++++++++++ frontend/src/components/layout.jsx | 16 + frontend/src/components/ui.jsx | 23 ++ frontend/src/index.css | 20 ++ frontend/src/main.jsx | 8 + frontend/src/pages/AdminLoginPage.jsx | 57 ++++ frontend/src/pages/AdminPlanningPage.jsx | 168 ++++++++++ frontend/src/pages/AdminRequestsPage.jsx | 165 ++++++++++ frontend/src/pages/PublicPage.jsx | 292 ++++++++++++++++++ frontend/src/utils.js | 17 + frontend/vite.config.js | 7 + 43 files changed, 1709 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/core/__init__.py create mode 100644 backend/core/apps.py create mode 100644 backend/core/management/__init__.py create mode 100644 backend/core/management/commands/__init__.py create mode 100644 backend/core/management/commands/ensure_admin.py create mode 100644 backend/core/management/commands/wait_for_db.py create mode 100644 backend/core/migrations/0001_initial.py create mode 100644 backend/core/migrations/__init__.py create mode 100644 backend/core/models.py create mode 100644 backend/core/notifications.py create mode 100644 backend/core/serializers.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/views.py create mode 100644 backend/entrypoint.sh create mode 100644 backend/manage.py create mode 100644 backend/planning/__init__.py create mode 100644 backend/planning/settings.py create mode 100644 backend/planning/urls.py create mode 100644 backend/planning/wsgi.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/components/ScheduleWeek.jsx create mode 100644 frontend/src/components/layout.jsx create mode 100644 frontend/src/components/ui.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminLoginPage.jsx create mode 100644 frontend/src/pages/AdminPlanningPage.jsx create mode 100644 frontend/src/pages/AdminRequestsPage.jsx create mode 100644 frontend/src/pages/PublicPage.jsx create mode 100644 frontend/src/utils.js create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..88d83b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Database Configuration +MYSQL_HOST=172.18.0.1 +MYSQL_PORT=3306 +MYSQL_DATABASE=planning +MYSQL_USER=planning +MYSQL_PASSWORD=change-me +MYSQL_ROOT_PASSWORD=change-me + +# Django +SECRET_KEY=change-me +DEBUG=False +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +ADMIN_FIRST_NAME=Admin +ADMIN_LAST_NAME= +ALLOWED_HOSTS=localhost,127.0.0.1,192.168.1.19,planning.example.com +CSRF_TRUSTED_ORIGINS=http://localhost:1025,http://localhost:1031,http://192.168.1.19:1025,http://192.168.1.19:1031,https://planning.example.com +CORS_ALLOWED_ORIGINS=http://localhost:1025,http://192.168.1.19:1025,https://planning.example.com +PUBLIC_SITE_URL=https://planning.example.com + +# Notifications (optional) +NTFY_SERVER=https://ntfy.sh +NTFY_TOPIC= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bce1085 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Secrets / environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +.venv/ + +# Node +node_modules/ +dist/ + +# Misc +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad0495 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Planning + +Petit site de planning public + demandes de disponibilité, avec une administration séparée. + +## Fonctionnalités + +### Public +- consulter le planning semaine par semaine ; +- voir l'état prévu : **Disponible**, **Occupé** ou **Indisponible** ; +- envoyer une demande de disponibilité immédiate sans compte ; +- suivre la réponse automatiquement avec un lien/token privé ; +- voir une durée de disponibilité si l'admin en indique une. + +### Administration +- connexion par compte admin ; +- créer une plage sur un jour ou plusieurs jours ; +- modifier/supprimer les plages directement depuis le planning ; +- recevoir et traiter les demandes ; +- accepter pour 15 min, 30 min, 1 h, une durée personnalisée, ou sans durée ; +- refuser avec un message optionnel ; +- actualisation automatique des demandes toutes les 15 secondes. + +## Stack +- Frontend : React + Vite + Tailwind CSS +- Backend : Django + Django REST Framework + JWT +- Base : MariaDB/MySQL externe +- Reverse proxy du frontend : Nginx +- Notifications optionnelles : ntfy + +## Ports +- Frontend : `1025` +- Backend : `1031` + +Le frontend proxy automatiquement `/api/` vers le backend. Pour le domaine public, le reverse proxy principal peut donc pointer uniquement sur : + +```text +http://127.0.0.1:1025 +``` + +Le backend reste aussi directement disponible sur : + +```text +http://127.0.0.1:1031 +``` + +## Lancement + +Le fichier `.env` fourni au déploiement contient la configuration réelle. Il est volontairement ignoré par Git. + +```bash +docker compose up -d --build +``` + +Logs : + +```bash +docker compose logs -f backend +docker compose logs -f frontend +``` + +Mise à jour : + +```bash +git pull +docker compose up -d --build +``` + +## Compte admin + +Au démarrage du backend, les variables suivantes créent ou mettent à jour automatiquement le compte admin : + +```env +ADMIN_USERNAME=... +ADMIN_PASSWORD=... +ADMIN_FIRST_NAME=... +ADMIN_LAST_NAME=... +``` + +L'administration est accessible sur : + +```text +/admin +``` + +## Notifications ntfy + +Le site fonctionne même sans notification externe. + +Pour activer les notifications : +1. installer l'app **ntfy** sur le téléphone ; +2. choisir un nom de topic difficile à deviner ; +3. s'abonner à ce topic dans l'app ; +4. renseigner ce même topic dans `.env` : + +```env +NTFY_SERVER=https://ntfy.sh +NTFY_TOPIC=mon-topic-prive +``` + +5. relancer le backend : + +```bash +docker compose restart backend +``` + +Une nouvelle demande publique déclenche alors une notification avec un lien vers `/admin/requests`. + +## Gitea + +Dépôt prévu : + +```text +https://gitea.louismazin.ovh/LouisMazin/Planning +``` + +Premier push depuis le dossier du projet : + +```bash +git init +git branch -M main +git remote add origin https://gitea.louismazin.ovh/LouisMazin/Planning.git +git add . +git commit -m "Initial planning app" +git push -u origin main +``` + +> `.env` n'est pas envoyé sur Git grâce au `.gitignore`. Garde-le uniquement sur le serveur. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6c36368 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN chmod +x /app/entrypoint.sh +EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..2d34c7a --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,4 @@ +from django.apps import AppConfig +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/backend/core/management/__init__.py b/backend/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/management/commands/__init__.py b/backend/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/management/commands/ensure_admin.py b/backend/core/management/commands/ensure_admin.py new file mode 100644 index 0000000..699ecef --- /dev/null +++ b/backend/core/management/commands/ensure_admin.py @@ -0,0 +1,16 @@ +import os +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +class Command(BaseCommand): + def handle(self, *args, **kwargs): + User = get_user_model() + username = os.getenv("ADMIN_USERNAME", "admin") + user, _ = User.objects.get_or_create(username=username) + user.first_name = os.getenv("ADMIN_FIRST_NAME", "Admin") + user.last_name = os.getenv("ADMIN_LAST_NAME", "") + user.is_staff = True + user.is_superuser = True + user.is_active = True + user.set_password(os.getenv("ADMIN_PASSWORD", "admin123")) + user.save() + self.stdout.write(self.style.SUCCESS(f"Admin ready: {username}")) diff --git a/backend/core/management/commands/wait_for_db.py b/backend/core/management/commands/wait_for_db.py new file mode 100644 index 0000000..6350771 --- /dev/null +++ b/backend/core/management/commands/wait_for_db.py @@ -0,0 +1,14 @@ +import time +from django.core.management.base import BaseCommand +from django.db import connections +from django.db.utils import OperationalError +class Command(BaseCommand): + def handle(self, *args, **kwargs): + for _ in range(60): + try: + connections["default"].cursor() + self.stdout.write(self.style.SUCCESS("Database available")) + return + except OperationalError: + time.sleep(2) + raise OperationalError("Database unavailable after 120 seconds") diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py new file mode 100644 index 0000000..9cf899e --- /dev/null +++ b/backend/core/migrations/0001_initial.py @@ -0,0 +1,35 @@ +from django.db import migrations, models +import uuid +class Migration(migrations.Migration): + initial = True + dependencies = [] + operations = [ + migrations.CreateModel(name="ScheduleEntry", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(blank=True, max_length=120)), + ("description", models.TextField(blank=True)), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ("start_time", models.TimeField()), + ("end_time", models.TimeField()), + ("status", models.CharField(choices=[("busy","Occupé"),("free","Disponible"),("unavailable","Indisponible")], default="busy", max_length=20)), + ("color", models.CharField(blank=True, default="", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], options={"ordering":["start_date","start_time"]}), + migrations.CreateModel(name="AvailabilityRequest", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("public_token", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("guest_name", models.CharField(max_length=80)), + ("message", models.CharField(blank=True, max_length=500)), + ("status", models.CharField(choices=[("pending","En attente"),("accepted","Acceptée"),("refused","Refusée")], default="pending", max_length=20)), + ("duration_minutes", models.PositiveIntegerField(blank=True, null=True)), + ("available_until", models.DateTimeField(blank=True, null=True)), + ("response_message", models.CharField(blank=True, max_length=500)), + ("requested_at", models.DateTimeField(auto_now_add=True)), + ("responded_at", models.DateTimeField(blank=True, null=True)), + ("requester_ip", models.GenericIPAddressField(blank=True, null=True)), + ], options={"ordering":["-requested_at"]}), + migrations.AddIndex(model_name="scheduleentry", index=models.Index(fields=["start_date","end_date"], name="core_sched_dates_idx")), + migrations.AddIndex(model_name="availabilityrequest", index=models.Index(fields=["status","requested_at"], name="core_req_status_idx")), + ] diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 0000000..f596797 --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,34 @@ +import uuid +from django.db import models + +class ScheduleEntry(models.Model): + STATUS_CHOICES = [("busy", "Occupé"), ("free", "Disponible"), ("unavailable", "Indisponible")] + title = models.CharField(max_length=120, blank=True) + description = models.TextField(blank=True) + start_date = models.DateField() + end_date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="busy") + color = models.CharField(max_length=16, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + class Meta: + ordering = ["start_date", "start_time"] + indexes = [models.Index(fields=["start_date", "end_date"], name="core_sched_dates_idx")] + +class AvailabilityRequest(models.Model): + STATUS_CHOICES = [("pending", "En attente"), ("accepted", "Acceptée"), ("refused", "Refusée")] + public_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + guest_name = models.CharField(max_length=80) + message = models.CharField(max_length=500, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + duration_minutes = models.PositiveIntegerField(null=True, blank=True) + available_until = models.DateTimeField(null=True, blank=True) + response_message = models.CharField(max_length=500, blank=True) + requested_at = models.DateTimeField(auto_now_add=True) + responded_at = models.DateTimeField(null=True, blank=True) + requester_ip = models.GenericIPAddressField(null=True, blank=True) + class Meta: + ordering = ["-requested_at"] + indexes = [models.Index(fields=["status", "requested_at"], name="core_req_status_idx")] diff --git a/backend/core/notifications.py b/backend/core/notifications.py new file mode 100644 index 0000000..dfc0094 --- /dev/null +++ b/backend/core/notifications.py @@ -0,0 +1,16 @@ +import os, requests + +def send_ntfy_notification(title, message, click_url=None): + topic = os.getenv("NTFY_TOPIC", "").strip() + if not topic: + return False + server = os.getenv("NTFY_SERVER", "https://ntfy.sh").rstrip("/") + headers = {"Title": title, "Priority": "high", "Tags": "calendar,question"} + if click_url: + headers["Click"] = click_url + try: + r = requests.post(f"{server}/{topic}", data=message.encode("utf-8"), headers=headers, timeout=8) + r.raise_for_status() + return True + except requests.RequestException: + return False diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 0000000..d1b3c98 --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,44 @@ +from rest_framework import serializers +from .models import AvailabilityRequest, ScheduleEntry + +class ScheduleEntrySerializer(serializers.ModelSerializer): + status_label = serializers.CharField(source="get_status_display", read_only=True) + class Meta: + model = ScheduleEntry + fields = "__all__" + read_only_fields = ["created_at", "updated_at"] + def validate(self, attrs): + sd = attrs.get("start_date", getattr(self.instance, "start_date", None)) + ed = attrs.get("end_date", getattr(self.instance, "end_date", None)) + st = attrs.get("start_time", getattr(self.instance, "start_time", None)) + et = attrs.get("end_time", getattr(self.instance, "end_time", None)) + if sd and ed and ed < sd: + raise serializers.ValidationError({"end_date": "La date de fin doit être après la date de début."}) + if st and et and et <= st: + raise serializers.ValidationError({"end_time": "L'heure de fin doit être après l'heure de début."}) + return attrs + +class AvailabilityRequestCreateSerializer(serializers.ModelSerializer): + class Meta: + model = AvailabilityRequest + fields = ["guest_name", "message"] + def validate_guest_name(self, value): + value = value.strip() + if len(value) < 2: + raise serializers.ValidationError("Indique ton prénom ou ton nom.") + return value + +class AvailabilityRequestPublicSerializer(serializers.ModelSerializer): + status_label = serializers.CharField(source="get_status_display", read_only=True) + class Meta: + model = AvailabilityRequest + fields = ["public_token", "guest_name", "message", "status", "status_label", "duration_minutes", "available_until", "response_message", "requested_at", "responded_at"] + +class AvailabilityRequestAdminSerializer(AvailabilityRequestPublicSerializer): + class Meta(AvailabilityRequestPublicSerializer.Meta): + fields = ["id"] + AvailabilityRequestPublicSerializer.Meta.fields + +class AvailabilityResponseSerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=["accepted", "refused"]) + duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1, max_value=1440) + response_message = serializers.CharField(required=False, allow_blank=True, max_length=500) diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..cefa237 --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from .views import * +router = DefaultRouter() +router.register(r"admin/schedule", AdminScheduleViewSet, basename="admin-schedule") +router.register(r"admin/requests", AdminAvailabilityRequestViewSet, basename="admin-requests") +urlpatterns = [ + path("public/schedule/", PublicScheduleView.as_view()), + path("public/requests/", PublicAvailabilityRequestCreateView.as_view()), + path("public/requests//", PublicAvailabilityRequestDetailView.as_view()), + path("admin/me/", AdminMeView.as_view()), + path("admin/requests//respond/", AdminAvailabilityRequestViewSet.as_view({"post": "respond"})), + path("", include(router.urls)), +] diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..d227e73 --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,98 @@ +from datetime import timedelta +import os +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.exceptions import Throttled +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView +from .models import AvailabilityRequest, ScheduleEntry +from .notifications import send_ntfy_notification +from .serializers import * + +def client_ip(request): + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + return forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR") + +class AdminTokenSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + if not self.user.is_staff: + from rest_framework.exceptions import AuthenticationFailed + raise AuthenticationFailed("Compte administrateur requis.") + data["user"] = {"username": self.user.username, "first_name": self.user.first_name, "last_name": self.user.last_name, "is_staff": True} + return data + +class AdminTokenView(TokenObtainPairView): + serializer_class = AdminTokenSerializer + +class PublicScheduleView(APIView): + permission_classes = [AllowAny] + def get(self, request): + qs = ScheduleEntry.objects.all() + start, end = request.query_params.get("start"), request.query_params.get("end") + if start and end: + qs = qs.filter(start_date__lte=end, end_date__gte=start) + return Response(ScheduleEntrySerializer(qs, many=True).data) + +class PublicAvailabilityRequestCreateView(APIView): + permission_classes = [AllowAny] + def post(self, request): + ip = client_ip(request) + if ip and AvailabilityRequest.objects.filter(requester_ip=ip, requested_at__gte=timezone.now()-timedelta(seconds=45)).exists(): + raise Throttled(detail="Une demande vient déjà d'être envoyée. Réessaie dans quelques secondes.") + s = AvailabilityRequestCreateSerializer(data=request.data) + s.is_valid(raise_exception=True) + item = s.save(requester_ip=ip) + site = os.getenv("PUBLIC_SITE_URL", "").rstrip("/") + msg = f"{item.guest_name} demande si tu es disponible maintenant." + if item.message: + msg += f"\n{item.message}" + send_ntfy_notification("Nouvelle demande de disponibilité", msg, f"{site}/admin/requests" if site else None) + return Response(AvailabilityRequestPublicSerializer(item).data, status=status.HTTP_201_CREATED) + +class PublicAvailabilityRequestDetailView(APIView): + permission_classes = [AllowAny] + def get(self, request, token): + try: + item = AvailabilityRequest.objects.get(public_token=token) + except AvailabilityRequest.DoesNotExist: + return Response({"detail": "Demande introuvable."}, status=404) + return Response(AvailabilityRequestPublicSerializer(item).data) + +class AdminScheduleViewSet(viewsets.ModelViewSet): + serializer_class = ScheduleEntrySerializer + permission_classes = [IsAdminUser] + def get_queryset(self): + qs = ScheduleEntry.objects.all() + start, end = self.request.query_params.get("start"), self.request.query_params.get("end") + return qs.filter(start_date__lte=end, end_date__gte=start) if start and end else qs + +class AdminAvailabilityRequestViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = AvailabilityRequestAdminSerializer + permission_classes = [IsAdminUser] + def get_queryset(self): + qs = AvailabilityRequest.objects.all() + status_filter = self.request.query_params.get("status") + return qs.filter(status=status_filter) if status_filter else qs + def respond(self, request, pk=None): + item = self.get_object() + s = AvailabilityResponseSerializer(data=request.data) + s.is_valid(raise_exception=True) + new_status = s.validated_data["status"] + duration = s.validated_data.get("duration_minutes") + item.status = new_status + item.duration_minutes = duration if new_status == "accepted" else None + item.available_until = timezone.now()+timedelta(minutes=duration) if new_status == "accepted" and duration else None + item.response_message = s.validated_data.get("response_message", "") + item.responded_at = timezone.now() + item.save() + return Response(AvailabilityRequestAdminSerializer(item).data) + +class AdminMeView(APIView): + permission_classes = [IsAdminUser] + def get(self, request): + u = request.user + return Response({"username": u.username, "first_name": u.first_name, "last_name": u.last_name, "is_staff": u.is_staff}) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..8442dc0 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e +python manage.py wait_for_db +python manage.py migrate --noinput +python manage.py ensure_admin +exec gunicorn planning.wsgi:application --bind 0.0.0.0:8000 --workers 2 --threads 4 --timeout 60 --access-logfile - --error-logfile - diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..3b1df48 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +import os, sys +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings") + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/backend/planning/__init__.py b/backend/planning/__init__.py new file mode 100644 index 0000000..063cd2c --- /dev/null +++ b/backend/planning/__init__.py @@ -0,0 +1,2 @@ +import pymysql +pymysql.install_as_MySQLdb() diff --git a/backend/planning/settings.py b/backend/planning/settings.py new file mode 100644 index 0000000..246c905 --- /dev/null +++ b/backend/planning/settings.py @@ -0,0 +1,72 @@ +from pathlib import Path +from datetime import timedelta +import os + +BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret") +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +def csv_env(name, default=""): + return [x.strip() for x in os.getenv(name, default).split(",") if x.strip()] + +ALLOWED_HOSTS = csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1") +CSRF_TRUSTED_ORIGINS = csv_env("CSRF_TRUSTED_ORIGINS") +CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS") +CORS_ALLOW_CREDENTIALS = True + +INSTALLED_APPS = [ + "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.messages", "django.contrib.staticfiles", "corsheaders", + "rest_framework", "core", +] +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "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", +] +ROOT_URLCONF = "planning.urls" +TEMPLATES = [{ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], "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 = "planning.wsgi.application" + +DATABASES = {"default": { + "ENGINE": "django.db.backends.mysql", + "NAME": os.getenv("MYSQL_DATABASE", "planning"), + "USER": os.getenv("MYSQL_USER", "planning"), + "PASSWORD": os.getenv("MYSQL_PASSWORD", ""), + "HOST": os.getenv("MYSQL_HOST", "127.0.0.1"), + "PORT": os.getenv("MYSQL_PORT", "3306"), + "OPTIONS": {"charset": "utf8mb4", "init_command": "SET sql_mode='STRICT_TRANS_TABLES'"}, +}} +AUTH_PASSWORD_VALIDATORS = [] +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_TZ = True +STATIC_URL = "static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), +} +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=12), + "REFRESH_TOKEN_LIFETIME": timedelta(days=14), + "ROTATE_REFRESH_TOKENS": True, +} +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG diff --git a/backend/planning/urls.py b/backend/planning/urls.py new file mode 100644 index 0000000..718f378 --- /dev/null +++ b/backend/planning/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, path +from rest_framework_simplejwt.views import TokenRefreshView +from core.views import AdminTokenView +urlpatterns = [ + path("api/auth/token/", AdminTokenView.as_view()), + path("api/auth/token/refresh/", TokenRefreshView.as_view()), + path("api/", include("core.urls")), +] diff --git a/backend/planning/wsgi.py b/backend/planning/wsgi.py new file mode 100644 index 0000000..b68681c --- /dev/null +++ b/backend/planning/wsgi.py @@ -0,0 +1,4 @@ +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings") +application = get_wsgi_application() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9698387 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +Django==5.2.7 +djangorestframework==3.16.1 +djangorestframework-simplejwt==5.5.1 +django-cors-headers==4.9.0 +gunicorn==23.0.0 +PyMySQL==1.1.2 +requests==2.32.5 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba44f91 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + backend: + build: ./backend + container_name: planning-backend + restart: unless-stopped + env_file: .env + ports: + - "1031:8000" + extra_hosts: + - "host.docker.internal:host-gateway" + + frontend: + build: ./frontend + container_name: planning-frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "1025:80" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..458e1c1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f4ff526 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Planning + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..bdc2614 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,25 @@ +map $http_x_forwarded_proto $planning_forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $planning_forwarded_proto; + proxy_set_header X-Forwarded-Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2d25d8f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "planning-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "axios": "^1.12.2", + "clsx": "^2.1.1", + "lucide-react": "^0.545.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.14", + "vite": "^7.1.9" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7704a14 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import { authApi } from './api'; +import { AdminLayout } from './components/layout'; +import PublicPage from './pages/PublicPage'; +import AdminLoginPage from './pages/AdminLoginPage'; +import AdminPlanningPage from './pages/AdminPlanningPage'; +import AdminRequestsPage from './pages/AdminRequestsPage'; + +function RequireAdmin({ children }) { + const location = useLocation(); + const [state, setState] = React.useState({ loading: true, ok: false }); + + React.useEffect(() => { + let active = true; + if (!localStorage.getItem('planning_access')) { + setState({ loading: false, ok: false }); + return () => { active = false; }; + } + + authApi.me() + .then(() => active && setState({ loading: false, ok: true })) + .catch(() => { + authApi.logout(); + if (active) setState({ loading: false, ok: false }); + }); + + return () => { active = false; }; + }, [location.pathname]); + + if (state.loading) { + return ( +
+
+ + Chargement… +
+
+ ); + } + + return state.ok ? children : ; +} + +function AdminRoute({ children }) { + return ( + + {children} + + ); +} + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..4a8cc08 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,23 @@ +import axios from 'axios'; +const client = axios.create({ baseURL:'/api', timeout:15000 }); +client.interceptors.request.use((config)=>{ + const token=localStorage.getItem('planning_access'); + if(token) config.headers.Authorization=`Bearer ${token}`; + return config; +}); +client.interceptors.response.use(r=>r, async(error)=>{ + const original=error.config; + if(error.response?.status===401 && !original?._retry && localStorage.getItem('planning_refresh')){ + original._retry=true; + try{ + const r=await axios.post('/api/auth/token/refresh/',{refresh:localStorage.getItem('planning_refresh')}); + localStorage.setItem('planning_access',r.data.access); + original.headers.Authorization=`Bearer ${r.data.access}`; + return client(original); + }catch{ + localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh'); + } + } + return Promise.reject(error); +}); +export default client; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..a57ff0f --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,19 @@ +import client from './client'; +export const publicApi={ + schedule: async(start,end)=>(await client.get('/public/schedule/',{params:{start,end}})).data, + createRequest: async(payload)=>(await client.post('/public/requests/',payload)).data, + requestStatus: async(token)=>(await client.get(`/public/requests/${token}/`)).data, +}; +export const authApi={ + login: async(username,password)=>{const {data}=await client.post('/auth/token/',{username,password}); localStorage.setItem('planning_access',data.access); localStorage.setItem('planning_refresh',data.refresh); return data;}, + me: async()=>(await client.get('/admin/me/')).data, + logout: ()=>{localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh');}, +}; +export const adminApi={ + schedule: async(start,end)=>(await client.get('/admin/schedule/',{params:{start,end}})).data, + createSchedule: async(p)=>(await client.post('/admin/schedule/',p)).data, + updateSchedule: async(id,p)=>(await client.put(`/admin/schedule/${id}/`,p)).data, + deleteSchedule: async(id)=>client.delete(`/admin/schedule/${id}/`), + requests: async()=>(await client.get('/admin/requests/')).data, + respond: async(id,p)=>(await client.post(`/admin/requests/${id}/respond/`,p)).data, +}; diff --git a/frontend/src/components/ScheduleWeek.jsx b/frontend/src/components/ScheduleWeek.jsx new file mode 100644 index 0000000..7eba1af --- /dev/null +++ b/frontend/src/components/ScheduleWeek.jsx @@ -0,0 +1,181 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Badge, Card } from './ui'; +import { dateKey } from '../utils'; + +const meta = { + busy: { + label: 'Occupé', + tone: 'amber', + bar: 'bg-amber-500/85 border-amber-600/30 text-slate-950', + }, + free: { + label: 'Disponible', + tone: 'green', + bar: 'bg-emerald-500/85 border-emerald-600/30 text-white', + }, + unavailable: { + label: 'Indisponible', + tone: 'red', + bar: 'bg-red-500/85 border-red-600/30 text-white', + }, +}; + +const mins = (time) => { + const [h, m] = String(time).split(':').map(Number); + return h * 60 + m; +}; + +const applies = (entry, day) => + entry.start_date <= dateKey(day) && entry.end_date >= dateKey(day); + +const ft = (time) => String(time).slice(0, 5); + +export default function ScheduleWeek({ days, entries, editable = false, onEntryClick }) { + const { startHour, endHour } = React.useMemo(() => { + const visible = entries.filter((entry) => days.some((day) => applies(entry, day))); + if (!visible.length) return { startHour: 6, endHour: 22 }; + + const earliest = Math.min(...visible.map((entry) => Math.floor(mins(entry.start_time) / 60))); + const latest = Math.max(...visible.map((entry) => Math.ceil(mins(entry.end_time) / 60))); + + return { + startHour: Math.max(0, Math.min(6, earliest - 1)), + endHour: Math.min(24, Math.max(22, latest + 1)), + }; + }, [days, entries]); + + const hourCount = Math.max(endHour - startHour, 1); + const totalMinutes = hourCount * 60; + const desktopHeight = Math.max(hourCount * 45, 540); + + const position = (entry) => { + const start = Math.max(mins(entry.start_time), startHour * 60); + const end = Math.min(mins(entry.end_time), endHour * 60); + return { + top: `${((start - startHour * 60) / totalMinutes) * 100}%`, + height: `${Math.max(((end - start) / totalMinutes) * 100, 2.2)}%`, + }; + }; + + return ( + +
+
+ {days.map((day) => ( +
+
+ {day.toLocaleDateString('fr-FR', { weekday: 'short' })} +
+
+ {day.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} +
+
+ ))} +
+ +
+
+ {Array.from({ length: hourCount + 1 }, (_, i) => ( +
+ {String(startHour + i).padStart(2, '0')}:00 +
+ ))} +
+ + {days.map((day) => ( +
+ {entries.filter((entry) => applies(entry, day)).map((entry) => { + const m = meta[entry.status] || meta.busy; + return ( + + ); + })} +
+ ))} +
+ +
+ {days.map((day) => { + const list = entries + .filter((entry) => applies(entry, day)) + .sort((a, b) => a.start_time.localeCompare(b.start_time)); + + return ( +
+
+
+
+ {day.toLocaleDateString('fr-FR', { weekday: 'long' })} +
+
+ {day.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })} +
+
+ {list.length} plage{list.length > 1 ? 's' : ''} +
+ + {!list.length ? ( +
+ Rien de renseigné. +
+ ) : ( +
+ {list.map((entry) => { + const m = meta[entry.status] || meta.busy; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ + ); +} diff --git a/frontend/src/components/layout.jsx b/frontend/src/components/layout.jsx new file mode 100644 index 0000000..3fd9f3d --- /dev/null +++ b/frontend/src/components/layout.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { CalendarDays, ClipboardList, LogOut, Menu, Wheat, X } from 'lucide-react'; +import clsx from 'clsx'; +import { Button } from './ui'; +import { authApi } from '../api'; + +export function PublicHeader(){return
Planning
Disponibilités
Administration
} + +export function AdminLayout({children}){ + const [open,setOpen]=React.useState(false); const navigate=useNavigate(); + const logout=()=>{authApi.logout(); navigate('/admin')}; + const nav=[['/admin/planning','Planning',CalendarDays],['/admin/requests','Demandes',ClipboardList]]; + const sidebar=
Planning
Administration
Voir le site
; + return
{open&&
{sidebar}
}
Administration
{children}
; +} diff --git a/frontend/src/components/ui.jsx b/frontend/src/components/ui.jsx new file mode 100644 index 0000000..10bdf21 --- /dev/null +++ b/frontend/src/components/ui.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import clsx from 'clsx'; +import { X } from 'lucide-react'; + +export function Button({children,className,variant='primary',size='md',type='button',...props}){ + const v={ + primary:'bg-amber-500 text-slate-950 hover:bg-amber-400 border-amber-500', + secondary:'bg-white text-slate-800 hover:bg-slate-50 border-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:border-slate-700 dark:hover:bg-slate-800', + danger:'bg-red-600 text-white hover:bg-red-500 border-red-600', + ghost:'bg-transparent text-slate-700 hover:bg-slate-100 border-transparent dark:text-slate-200 dark:hover:bg-slate-800', + success:'bg-emerald-600 text-white hover:bg-emerald-500 border-emerald-600', + }; + const s={sm:'h-9 px-3 text-sm',md:'h-10 px-4 text-sm',lg:'h-12 px-5 text-base',icon:'size-10 p-0'}; + return ; +} +export function Card({children,className}){return
{children}
} +export function Badge({children,tone='slate'}){const t={slate:'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',amber:'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',green:'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',red:'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300'}; return {children}} +export function Input({className,...props}){return } +export function Textarea({className,...props}){return