diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..3d91f41c50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/__pycache__ +*.pyc +venv +.venv +.pytest_cache +.git +.gitignore +.coverage +staticfiles/ +oc_lettings_site\storage.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000000..c94d08dd8e --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,63 @@ +name: CI pipeline + +on: + push: + branches : + - feature/refactor-apps + pull_request: + branches : + - feature/refactor-apps + + +jobs: + test-lint-build: + runs-on: ubuntu-latest + + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} + SENTRY_KEY: ${{ secrets.SENTRY_KEY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install flake8 pytest + + - name: Lint with flake8 + run: | + echo "Running flake8 with setup.cfg" + flake8 . + + - name: Run tests with pytest + run: | + echo "Running tests ..." + pytest + + + - name: Docker login + uses: docker/login-action@v2 + with: + registry: docker.io + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/my-django-app:latest + + - name: Trigger Render Deploy Hook + run: curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4405ebab4..a93ecf706a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ **/__pycache__ *.pyc venv +.venv +.pytest_cache +.env +.coverage +staticfiles/ +build/ \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000..96ec8ec420 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,25 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.11" + + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/source/conf.py + builder: html + + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: requirements.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..d577bc0140 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1\ + DEBUG=False \ + DJANGO_SETTINGS_MODULE=oc_lettings_site.settings + + +ARG SECRET_KEY=xtokenforbuildonly +ENV SECRET_KEY=${SECRET_KEY} + +WORKDIR /app + +COPY . /app/ + + +RUN pip install --no-cache-dir --upgrade pip setuptools wheel +RUN pip install --no-cache-dir -r requirements.txt + +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "oc_lettings_site.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/README.md b/README.md index c8547803f7..9a6a691b33 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,81 @@ Utilisation de PowerShell, comme ci-dessus sauf : - Pour activer l'environnement virtuel, `.\venv\Scripts\Activate.ps1` - Remplacer `which ` par `(Get-Command ).Path` + +### Fichier .env +Le projet utilise un fichier `.env` pour centraliser les variables d'environnement sensibles. + +Créez un fichier `.env` à la racine du projet avec comme variable: + - SECRET_KEY + - SENTRY_DSN + - SECRET_DEPLOY_HOOK + +Ne pas commit ce fichier dans Git, il doit être listé dans le `.gitignore` + + +### Docker + +L'application dispose d'un `Dockerfile` pour créer une image Docker exécutable: +commande pour la création de l'image `docker build -t oc-lettings .` + +Pour la partie CI/CD: +- Créer un compte docker sur Docker Hub (https://hub.docker.com/) +- Configurer vos identifiants dans les **secrets GitHub**: + - `DOCKER_USERNAME` + - `DOCKER_PASSWORD`(sous forme de token) + +### CI/CD: + +Un pipeline GitHub a été ajouté, déclenché à chaque push. +Il execute automatiquement les étapes suivantes: + +- Lint avec `flake8` +- Tests unitaires avec `pytest` +- Build Docker +- Push vers Docker Hub +- Génération de la documentation + + +### Documentation + +La documentation technique est générée avec **Sphinx** et hebergée sur **Read the Docs**: + +Créer sa propre doc + +1. Créer un compte sur https://readthedocs.org +2. Connecter votre repo GitHub +3. Importer le projet +4. S'assurer que `.readthedocs.yml` est présent à la racine +5. Déclencher un build + +### Sentry + +Étapes pour intégrer Sentry: + + 1. Créer un compte sur https://sentry.io + 2. Créer un projet Django + 3. Copier la clé DSN dans votre environnement de variable (.env) + 4. Ajouter le bloc de code de sentry générée dans sentry et la Clé DSN dans `oc_lettings_site/settings.py` + + +### Déploiement + +Render est connecté à Docker Hub pour tirer l'image automatiquement. + +**Étapes pour configurer** + +1. Créer un compte render (https://render.com) +2. Créer un Web service +3. Indiquer l'image Docker : +``` + docker.io/nom utilisateur/nom de l'application + +``` +4. Déploiement automatique à chaque image pushée + +Exemple de lien de production : + https://oc-lettings.onrender.com + + + + diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000..d0c3cbf102 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000..dc1312ab09 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000000..b5d3b522dc --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,44 @@ +import os +import sys + +import django + +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +sys.path.insert(0, os.path.abspath("../..")) + +os.environ["DJANGO_SETTINGS_MODULE"] = "docs.source.doc_settings" + + +django.setup() + +project = "oc-lettings" +copyright = "2025, mehdi" +author = "mehdi" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +templates_path = ["_templates"] +exclude_patterns = [] + +language = "fr" +local_dirs = ["locales/"] +gettex_compact = False + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docs/source/doc_settings.py b/docs/source/doc_settings.py new file mode 100644 index 0000000000..6b91daea6c --- /dev/null +++ b/docs/source/doc_settings.py @@ -0,0 +1,18 @@ +SECRET_KEY = "clef_secrète_pour_la_doc" + + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "oc_lettings_site", + "lettings", + "profiles", +] + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000..d1589f5f92 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,19 @@ +.. oc-lettings documentation master file, created by + sphinx-quickstart on Fri May 30 13:39:36 2025. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +oc-lettings documentation +========================= + +Add your content using ``reStructuredText`` syntax. See the +`reStructuredText `_ +documentation for details. + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + presentation + modules \ No newline at end of file diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000000..983023dea0 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,16 @@ +Documentation des modules +========================= + + +.. automodule:: lettings.models + :members: + + +.. automodule:: lettings.views + :members: + +.. automodule:: profiles.models + :members: + +.. automodule:: profiles.views + :members: \ No newline at end of file diff --git a/docs/source/presentation.rst b/docs/source/presentation.rst new file mode 100644 index 0000000000..5120dac87b --- /dev/null +++ b/docs/source/presentation.rst @@ -0,0 +1,11 @@ +Présentation du projet +====================== + + + +Ce projet est un site Django qui permet de gérer des annonces de locations et des profils utilisateurs. + +Fonctionalités : +- Navigation des profils +- Liste des locations +- Accés à l'administraiion du site via Django admin \ No newline at end of file diff --git a/lettings/__init__.py b/lettings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lettings/admin.py b/lettings/admin.py new file mode 100644 index 0000000000..917b65850d --- /dev/null +++ b/lettings/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import Address, Letting + +admin.site.register(Letting) +admin.site.register(Address) diff --git a/lettings/apps.py b/lettings/apps.py new file mode 100644 index 0000000000..9fd4be62a2 --- /dev/null +++ b/lettings/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LettingsConfig(AppConfig): + name = "lettings" diff --git a/lettings/migrations/0001_initial.py b/lettings/migrations/0001_initial.py new file mode 100644 index 0000000000..696457c940 --- /dev/null +++ b/lettings/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 3.0 on 2025-05-15 08:29 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Address", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "number", + models.PositiveIntegerField( + validators=[django.core.validators.MaxValueValidator(9999)] + ), + ), + ("street", models.CharField(max_length=64)), + ("city", models.CharField(max_length=64)), + ( + "state", + models.CharField( + max_length=2, + validators=[django.core.validators.MinLengthValidator(2)], + ), + ), + ( + "zip_code", + models.PositiveIntegerField( + validators=[django.core.validators.MaxValueValidator(99999)] + ), + ), + ( + "country_iso_code", + models.CharField( + max_length=3, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ], + ), + migrations.CreateModel( + name="Letting", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=256)), + ( + "address", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="lettings.Address", + ), + ), + ], + ), + ] diff --git a/lettings/migrations/__init__.py b/lettings/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lettings/models.py b/lettings/models.py new file mode 100644 index 0000000000..c8004b1efc --- /dev/null +++ b/lettings/models.py @@ -0,0 +1,50 @@ +from django.core.validators import MaxValueValidator, MinLengthValidator +from django.db import models + + +class Address(models.Model): + """ + Représente une adresse postale complète. + + Contient les informations nécessaires pour identifier un lieu : + numéro, rue, ville, état, code postal du pays. + """ + + number = models.PositiveIntegerField(validators=[MaxValueValidator(9999)]) + street = models.CharField(max_length=64) + city = models.CharField(max_length=64) + state = models.CharField(max_length=2, validators=[MinLengthValidator(2)]) + zip_code = models.PositiveIntegerField(validators=[MaxValueValidator(99999)]) + country_iso_code = models.CharField( + max_length=3, validators=[MinLengthValidator(3)] + ) + + class Meta: + verbose_name_plural = "Addresses" + + def __str__(self): + """ + Retourne une version lisible de l’adresse (numéro et rue). + """ + return f"{self.number} {self.street}" + + +class Letting(models.Model): + """ + Représente une location disponible. + + Contient un titre descriptif et une relation + vers une adresse unique associée à cette location. + """ + + title = models.CharField(max_length=256) + address = models.OneToOneField(Address, on_delete=models.CASCADE) + + class Meta: + verbose_name_plural = "Lettings" + + def __str__(self): + """ + Retourne le titre de la location. + """ + return self.title diff --git a/templates/lettings_index.html b/lettings/templates/lettings/index.html similarity index 89% rename from templates/lettings_index.html rename to lettings/templates/lettings/index.html index 92857a78d9..a85f3a348e 100644 --- a/templates/lettings_index.html +++ b/lettings/templates/lettings/index.html @@ -20,7 +20,7 @@

Lettings

@@ -36,7 +36,7 @@

Lettings

Home - + Profiles diff --git a/templates/letting.html b/lettings/templates/lettings/letting.html similarity index 95% rename from templates/letting.html rename to lettings/templates/lettings/letting.html index 7e5f3a73fd..252d68035e 100644 --- a/templates/letting.html +++ b/lettings/templates/lettings/letting.html @@ -25,14 +25,14 @@

{{ title }}

diff --git a/lettings/tests.py b/lettings/tests.py new file mode 100644 index 0000000000..ea65b4fc2f --- /dev/null +++ b/lettings/tests.py @@ -0,0 +1,51 @@ +from django.test import TestCase +from django.urls import reverse + +from .models import Address, Letting + + +class LettingsViewsTests(TestCase): + """ + Tests unitaires des vues de l'application Lettings. + + Vérifie que les pages de liste et de détail des locations + répondent correctement et affichent les bonnes informations. + """ + + def setUp(self): + """ + Initialise les données de test : une adresse et une location. + + Cette méthode est exécutée avant chaque test pour garantir + un environnement de test cohérent. + """ + self.address = Address.objects.create( + number=123, + street="Main street", + city="Miami", + state="Florida", + zip_code=98210, + country_iso_code="USA", + ) + + self.letting = Letting.objects.create(title="House", address=self.address) + + def test_lettings_index_view(self): + """ + Vérifie que la page d’index des locations retourne un code 200 + et contient le titre de la location. + """ + url = reverse("lettings:index") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "House") + + def test_lettings_detail_view(self): + """ + Vérifie que la page de détail d’une location retourne un code 200 + et contient l’adresse de la location. + """ + url = reverse("lettings:letting", args=[self.letting.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Main street") diff --git a/lettings/urls.py b/lettings/urls.py new file mode 100644 index 0000000000..bb049b05c4 --- /dev/null +++ b/lettings/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +app_name = "lettings" + +urlpatterns = [ + path("", views.index, name="index"), + path("/", views.letting, name="letting"), +] diff --git a/lettings/views.py b/lettings/views.py new file mode 100644 index 0000000000..4d3e646a3a --- /dev/null +++ b/lettings/views.py @@ -0,0 +1,43 @@ +import logging + +from django.http import Http404 +from django.shortcuts import render + +from lettings.models import Letting + +# Create your views here. + +logger = logging.getLogger(__name__) + + +def index(request): + """ + Affiche la liste de toutes les locations. + + Récupère tous les objets Letting depuis la base de données + et les passe au template 'lettings/index.html' via le contexte. + """ + lettings_list = Letting.objects.all() + context = {"lettings_list": lettings_list} + return render(request, "lettings/index.html", context) + + +def letting(request, letting_id): + """ + Affiche le détail d'une location spécifique. + + Utilise l'ID fourni dans l'URL pour récupérer un objet Letting, + puis transmet son titre et son adresse au template 'lettings/letting.html'. + """ + try: + letting = Letting.objects.get(id=letting_id) + logger.info(f"Letting accédé : {letting.title}, ID: {letting_id}") + except Letting.DoesNotExist: + logger.error(f"Letting non trouvé pout l'ID : {letting_id}") + raise Http404("Letting non trouvé") + + context = { + "title": letting.title, + "address": letting.address, + } + return render(request, "lettings/letting.html", context) diff --git a/manage.py b/manage.py index c0e27e034a..9ee5ca3e04 100755 --- a/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oc_lettings_site.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -15,5 +15,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/oc-lettings-site.sqlite3 b/oc-lettings-site.sqlite3 index 3d885414f9..3501d7b508 100644 Binary files a/oc-lettings-site.sqlite3 and b/oc-lettings-site.sqlite3 differ diff --git a/oc_lettings_site/admin.py b/oc_lettings_site/admin.py index 63328c6dd3..e69de29bb2 100644 --- a/oc_lettings_site/admin.py +++ b/oc_lettings_site/admin.py @@ -1,10 +0,0 @@ -from django.contrib import admin - -from .models import Letting -from .models import Address -from .models import Profile - - -admin.site.register(Letting) -admin.site.register(Address) -admin.site.register(Profile) diff --git a/oc_lettings_site/apps.py b/oc_lettings_site/apps.py index 6489692f04..df4f565ffc 100644 --- a/oc_lettings_site/apps.py +++ b/oc_lettings_site/apps.py @@ -2,4 +2,4 @@ class OCLettingsSiteConfig(AppConfig): - name = 'oc_lettings_site' + name = "oc_lettings_site" diff --git a/oc_lettings_site/asgi.py b/oc_lettings_site/asgi.py index 61f2d23ba3..318cce1ff9 100644 --- a/oc_lettings_site/asgi.py +++ b/oc_lettings_site/asgi.py @@ -2,6 +2,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oc_lettings_site.settings") application = get_asgi_application() diff --git a/oc_lettings_site/migrations/0001_initial.py b/oc_lettings_site/migrations/0001_initial.py index 774cf23f58..8655dcc13d 100644 --- a/oc_lettings_site/migrations/0001_initial.py +++ b/oc_lettings_site/migrations/0001_initial.py @@ -1,9 +1,9 @@ # Generated by Django 3.0 on 2020-06-14 09:35 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -16,31 +16,89 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Address', + name="Address", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('number', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(9999)])), - ('street', models.CharField(max_length=64)), - ('city', models.CharField(max_length=64)), - ('state', models.CharField(max_length=2, validators=[django.core.validators.MinLengthValidator(2)])), - ('zip_code', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(99999)])), - ('country_iso_code', models.CharField(max_length=3, validators=[django.core.validators.MinLengthValidator(3)])), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "number", + models.PositiveIntegerField( + validators=[django.core.validators.MaxValueValidator(9999)] + ), + ), + ("street", models.CharField(max_length=64)), + ("city", models.CharField(max_length=64)), + ( + "state", + models.CharField( + max_length=2, + validators=[django.core.validators.MinLengthValidator(2)], + ), + ), + ( + "zip_code", + models.PositiveIntegerField( + validators=[django.core.validators.MaxValueValidator(99999)] + ), + ), + ( + "country_iso_code", + models.CharField( + max_length=3, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), ], ), migrations.CreateModel( - name='Profile', + name="Profile", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('favorite_city', models.CharField(blank=True, max_length=64)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("favorite_city", models.CharField(blank=True, max_length=64)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='Letting', + name="Letting", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=256)), - ('address', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oc_lettings_site.Address')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=256)), + ( + "address", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="oc_lettings_site.Address", + ), + ), ], ), ] diff --git a/oc_lettings_site/models.py b/oc_lettings_site/models.py index ed255e8c11..e69de29bb2 100644 --- a/oc_lettings_site/models.py +++ b/oc_lettings_site/models.py @@ -1,31 +0,0 @@ -from django.db import models -from django.core.validators import MaxValueValidator, MinLengthValidator -from django.contrib.auth.models import User - - -class Address(models.Model): - number = models.PositiveIntegerField(validators=[MaxValueValidator(9999)]) - street = models.CharField(max_length=64) - city = models.CharField(max_length=64) - state = models.CharField(max_length=2, validators=[MinLengthValidator(2)]) - zip_code = models.PositiveIntegerField(validators=[MaxValueValidator(99999)]) - country_iso_code = models.CharField(max_length=3, validators=[MinLengthValidator(3)]) - - def __str__(self): - return f'{self.number} {self.street}' - - -class Letting(models.Model): - title = models.CharField(max_length=256) - address = models.OneToOneField(Address, on_delete=models.CASCADE) - - def __str__(self): - return self.title - - -class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) - favorite_city = models.CharField(max_length=64, blank=True) - - def __str__(self): - return self.user.username diff --git a/oc_lettings_site/settings.py b/oc_lettings_site/settings.py index a18bee8106..c424e76526 100644 --- a/oc_lettings_site/settings.py +++ b/oc_lettings_site/settings.py @@ -1,7 +1,20 @@ import os - from pathlib import Path +import sentry_sdk +from dotenv import load_dotenv + +load_dotenv() + + +sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN"), + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, +) + + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = Path(__file__).resolve().parent.parent @@ -10,64 +23,67 @@ # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'fp$9^593hsriajg$_%=5trot9g!1qa@ew(o-1#@=&4%=hp46(s' +SECRET_KEY = os.getenv("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1,localhost").split(",") +# ["127.0.0.1", "localhost"] # Application definition INSTALLED_APPS = [ - 'oc_lettings_site.apps.OCLettingsSiteConfig', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "lettings", + "profiles", ] 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', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "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 = 'oc_lettings_site.urls' +ROOT_URLCONF = "oc_lettings_site.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'oc_lettings_site.wsgi.application' +WSGI_APPLICATION = "oc_lettings_site.wsgi.application" # Database # https://docs.djangoproject.com/en/3.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'oc-lettings-site.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "oc-lettings-site.sqlite3"), } } @@ -77,16 +93,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -94,9 +110,9 @@ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -108,7 +124,12 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") + +STATIC_URL = "/static/" +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "static"), +] + -STATIC_URL = '/static/' -STATICFILES_DIRS = [BASE_DIR / "static",] +STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" diff --git a/oc_lettings_site/urls.py b/oc_lettings_site/urls.py index f0ff5897ab..90ec08469b 100644 --- a/oc_lettings_site/urls.py +++ b/oc_lettings_site/urls.py @@ -1,13 +1,11 @@ from django.contrib import admin -from django.urls import path +from django.urls import include, path -from . import views +from oc_lettings_site import views as core_view urlpatterns = [ - path('', views.index, name='index'), - path('lettings/', views.lettings_index, name='lettings_index'), - path('lettings//', views.letting, name='letting'), - path('profiles/', views.profiles_index, name='profiles_index'), - path('profiles//', views.profile, name='profile'), - path('admin/', admin.site.urls), + path("", core_view.index, name="index"), + path("lettings/", include("lettings.urls", namespace="lettings")), + path("profiles/", include("profiles.urls", namespace="profiles")), + path("admin/", admin.site.urls), ] diff --git a/oc_lettings_site/views.py b/oc_lettings_site/views.py index a72db27074..0c84243fd0 100644 --- a/oc_lettings_site/views.py +++ b/oc_lettings_site/views.py @@ -1,45 +1,10 @@ from django.shortcuts import render -from .models import Letting, Profile - - -# Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque molestie quam lobortis leo consectetur ullamcorper non id est. Praesent dictum, nulla eget feugiat sagittis, sem mi convallis eros, -# vitae dapibus nisi lorem dapibus sem. Maecenas pharetra purus ipsum, eget consequat ipsum lobortis quis. Phasellus eleifend ex auctor venenatis tempus. -# Aliquam vitae erat ac orci placerat luctus. Nullam elementum urna nisi, pellentesque iaculis enim cursus in. Praesent volutpat porttitor magna, non finibus neque cursus id. def index(request): - return render(request, 'index.html') - -# Aenean leo magna, vestibulum et tincidunt fermentum, consectetur quis velit. Sed non placerat massa. Integer est nunc, pulvinar a -# tempor et, bibendum id arcu. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Cras eget scelerisque -def lettings_index(request): - lettings_list = Letting.objects.all() - context = {'lettings_list': lettings_list} - return render(request, 'lettings_index.html', context) - - -#Cras ultricies dignissim purus, vitae hendrerit ex varius non. In accumsan porta nisl id eleifend. Praesent dignissim, odio eu consequat pretium, purus urna vulputate arcu, vitae efficitur -# lacus justo nec purus. Aenean finibus faucibus lectus at porta. Maecenas auctor, est ut luctus congue, dui enim mattis enim, ac condimentum velit libero in magna. Suspendisse potenti. In tempus a nisi sed laoreet. -# Suspendisse porta dui eget sem accumsan interdum. Ut quis urna pellentesque justo mattis ullamcorper ac non tellus. In tristique mauris eu velit fermentum, tempus pharetra est luctus. Vivamus consequat aliquam libero, eget bibendum lorem. Sed non dolor risus. Mauris condimentum auctor elementum. Donec quis nisi ligula. Integer vehicula tincidunt enim, ac lacinia augue pulvinar sit amet. -def letting(request, letting_id): - letting = Letting.objects.get(id=letting_id) - context = { - 'title': letting.title, - 'address': letting.address, - } - return render(request, 'letting.html', context) - -# Sed placerat quam in pulvinar commodo. Nullam laoreet consectetur ex, sed consequat libero pulvinar eget. Fusc -# faucibus, urna quis auctor pharetra, massa dolor cursus neque, quis dictum lacus d -def profiles_index(request): - profiles_list = Profile.objects.all() - context = {'profiles_list': profiles_list} - return render(request, 'profiles_index.html', context) + """ + Affiche la page d'accueil du site. -# Aliquam sed metus eget nisi tincidunt ornare accumsan eget lac -# laoreet neque quis, pellentesque dui. Nullam facilisis pharetra vulputate. Sed tincidunt, dolor id facilisis fringilla, eros leo tristique lacus, -# it. Nam aliquam dignissim congue. Pellentesque habitant morbi tristique senectus et netus et males -def profile(request, username): - profile = Profile.objects.get(user__username=username) - context = {'profile': profile} - return render(request, 'profile.html', context) + Rend le template 'index.html' sans contexte particulier. + """ + return render(request, "index.html") diff --git a/oc_lettings_site/wsgi.py b/oc_lettings_site/wsgi.py index d78ca6d669..7b4afb7e59 100644 --- a/oc_lettings_site/wsgi.py +++ b/oc_lettings_site/wsgi.py @@ -2,6 +2,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oc_lettings_site.settings") application = get_wsgi_application() diff --git a/profiles/__init__.py b/profiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/profiles/admin.py b/profiles/admin.py new file mode 100644 index 0000000000..a599326416 --- /dev/null +++ b/profiles/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from profiles.models import Profile + +admin.site.register(Profile) diff --git a/profiles/apps.py b/profiles/apps.py new file mode 100644 index 0000000000..b2e500858e --- /dev/null +++ b/profiles/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ProfilesConfig(AppConfig): + """ + Configuration de l'application Django 'profiles'. + + Cette configuration permet à Django de reconnaître et initialiser + l'application lors du démarrage du projet. + """ + + name = "profiles" diff --git a/profiles/migrations/0001_initial.py b/profiles/migrations/0001_initial.py new file mode 100644 index 0000000000..414b23fe67 --- /dev/null +++ b/profiles/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 3.0 on 2025-05-15 08:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("favorite_city", models.CharField(blank=True, max_length=64)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/profiles/migrations/__init__.py b/profiles/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/profiles/models.py b/profiles/models.py new file mode 100644 index 0000000000..4cd30a5182 --- /dev/null +++ b/profiles/models.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import User +from django.db import models + + +class Profile(models.Model): + """ + Représente un profil client indépendant dans l'application. + + Ce modèle permet de stocker des informations client telles que + le nom d'utilisateur et la ville favorite. + """ + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") + favorite_city = models.CharField(max_length=64, blank=True) + + class Meta: + verbose_name_plural = "Profiles" + + def __str__(self): + return self.user.username diff --git a/templates/profiles_index.html b/profiles/templates/profiles/index.html similarity index 88% rename from templates/profiles_index.html rename to profiles/templates/profiles/index.html index 4ad1daf92f..563b7a0166 100644 --- a/templates/profiles_index.html +++ b/profiles/templates/profiles/index.html @@ -18,7 +18,7 @@

Profiles

@@ -34,7 +34,7 @@

Profiles

Home - + Lettings
diff --git a/templates/profile.html b/profiles/templates/profiles/profile.html similarity index 96% rename from templates/profile.html rename to profiles/templates/profiles/profile.html index d150d30e63..4b1af37496 100644 --- a/templates/profile.html +++ b/profiles/templates/profiles/profile.html @@ -24,14 +24,14 @@

{{ profile.user.username }}

diff --git a/profiles/tests.py b/profiles/tests.py new file mode 100644 index 0000000000..9d45a0a23f --- /dev/null +++ b/profiles/tests.py @@ -0,0 +1,43 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from .models import Profile + + +class ProfilesTests(TestCase): + """ + Tests unitaires des vues de l'application Profiles. + + Vérifie que les pages de liste et de détail des profils + répondent correctement et affichent les bonnes informations. + """ + + def setUp(self): + """ + Initialise un utilisateur et son profil associé pour les tests. + + Crée un utilisateur nommé 'dan' et un profil avec Paris comme ville favorite. + """ + self.user = User.objects.create_user(username="dan", password="1234") + self.profile = Profile.objects.create(user=self.user, favorite_city="Paris") + + def test_profile_index_view(self): + """ + Vérifie que la page d’index des profils retourne un code 200 + et contient le mot 'Profiles'. + """ + response = self.client.get(reverse("profiles:index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Profiles") + + def test_profile_detail_view(self): + """ + Vérifie que la page de détail d’un profil retourne un code 200 + et contient la ville favorite du profil. + """ + response = self.client.get( + reverse("profiles:profile", args=[self.user.username]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Paris") diff --git a/profiles/urls.py b/profiles/urls.py new file mode 100644 index 0000000000..beef005637 --- /dev/null +++ b/profiles/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +app_name = "profiles" + +urlpatterns = [ + path("", views.index, name="index"), + path("/", views.profile, name="profile"), +] diff --git a/profiles/views.py b/profiles/views.py new file mode 100644 index 0000000000..9be054f271 --- /dev/null +++ b/profiles/views.py @@ -0,0 +1,42 @@ +import logging + +from django.http import Http404 +from django.shortcuts import render + +from profiles.models import Profile + +# Create your views here. + + +logger = logging.getLogger(__name__) + + +def index(request): + """ + Affiche la liste de tous les profils clients. + + Récupère tous les objets Profile de la base de données et les transmet + au template 'profiles/index.html' via le contexte. + """ + profiles_list = Profile.objects.all() + context = {"profiles_list": profiles_list} + return render(request, "profiles/index.html", context) + + +def profile(request, username): + """ + Affiche le détail d’un profil client à partir du nom d’utilisateur. + + Utilise le nom d'utilisateur fourni dans l'URL pour retrouver + le profil associé via la relation au modèle User, et transmet + cet objet au template 'profiles/profile.html'. + """ + try: + profile = Profile.objects.get(user__username=username) + logger.info(f"Profile consulté : {username}") + except Profile.DoesNotExist: + logger.error(f"Profile introuvable pour l'utilisateur: {username}") + raise Http404("profile non trouvé") + + context = {"profile": profile} + return render(request, "profiles/profile.html", context) diff --git a/requirements.txt b/requirements.txt index c48c84ea40..be743f5cce 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/setup.cfg b/setup.cfg index 9346841bbc..d3ff3827ae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] max-line-length = 99 -exclude = **/migrations/*,venv +exclude = + **/migrations/*, + .venv, + venv, + __pycache__ [tool:pytest] DJANGO_SETTINGS_MODULE = oc_lettings_site.settings diff --git a/static/css/styles.css b/static/css/styles.css index 53c1b18d5f..83a4966aa2 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -18600,11 +18600,11 @@ body { } .card-waves .card-body { - background-image: url("../assets/img/backgrounds/bg-waves.svg"); + /*background-image: url("../assets/img/backgrounds/bg-waves.svg");*/ } .card-angles .card-body { - background-image: url("../assets/img/backgrounds/bg-angles.svg"); + /*background-image: url("../assets/img/backgrounds/bg-angles.svg");*/ } .card-flag { @@ -20057,7 +20057,7 @@ section { .device[data-device=iPhoneX][data-orientation=portrait][data-color=black] { padding-bottom: 198.89807163%; - background-image: url("../assets/img/device-mockups/iPhoneX/portrait.png"); + /*background-image: url("../assets/img/device-mockups/iPhoneX/portrait.png");*/ z-index: initial; } @@ -20072,7 +20072,7 @@ section { .device[data-device=iPhoneX][data-orientation=landscape][data-color=black] { padding-bottom: 50.27700831%; - background-image: url("../assets/img/device-mockups/iPhoneX/landscape.png"); + /*background-image: url("../assets/img/device-mockups/iPhoneX/landscape.png");*/ z-index: initial; } @@ -20098,7 +20098,7 @@ section { .device[data-device=iPhoneX][data-orientation=portrait][data-color=black]::after { content: ""; - background-image: url("../assets/img/device-mockups/iPhoneX/portrait_black.png"); + /*background-image: url("../assets/img/device-mockups/iPhoneX/portrait_black.png");*/ } .device[data-device=iPhoneX][data-orientation=portrait][data-color=black] .button { @@ -20122,7 +20122,7 @@ section { .device[data-device=iPhoneX][data-orientation=landscape][data-color=black]::after { content: ""; - background-image: url("../assets/img/device-mockups/iPhoneX/landscape_black.png"); + /*background-image: url("../assets/img/device-mockups/iPhoneX/landscape_black.png");*/ } .device[data-device=iPhoneX][data-orientation=landscape][data-color=black] .button { diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000000..ac4989df06 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

404 - Page non trouvée

+

La page que vous recherchez n'existe pas.

+Retour à l'acceuil +{% endblock %} \ No newline at end of file diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000000..ac12f8212b --- /dev/null +++ b/templates/500.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

500 - Erreur interne du service

+

Une erreur s'est produite. Veuillez réessayer ultérieurement

+Retour à l'acceuil +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index ab7addba01..403b342755 100644 --- a/templates/base.html +++ b/templates/base.html @@ -24,10 +24,10 @@
Logo Orange County Lettings diff --git a/templates/index.html b/templates/index.html index 71a8e61a46..877d0a613b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,17 +7,17 @@
-

Welcome to Holiday Homes

+

Welcomz to Holiday Homes