diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000..4d0a77c49a Binary files /dev/null and b/.coverage differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..3bf2c648a6 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +omit = + */tests/* + */migrations/* + */__init__.py \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..564b105d7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +.git +.gitignore +__pycache__/ +*.pyc +*.pyo +*.pyd \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000000..3667be71ea --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,86 @@ +name: Pipeline + +on: + push: + pull_request: + +jobs: + compile: + + runs-on: ubuntu-latest + + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + USE_SQLITE: "True" + + steps: + # 1. Checkout + - name: Checkout repository + uses: actions/checkout@v4 + + # 2. Install Python + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + # 3. Install dependencies + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + # 4. Lint (flake8) + - name: Run flake8 + run: flake8 . + + # 5. Tests + coverage + - name: Run tests with coverage + run: pytest --cov=. --cov-report=term-missing --cov-fail-under=80 + + + containerize: + + needs: compile + + if: github.ref == 'refs/heads/master' + + runs-on: ubuntu-latest + + steps: + # 1. Checkout repository + - name: Checkout repository + uses: actions/checkout@v4 + + # 2. Login Docker Hub + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + # 3. Build Docker image (SHA + latest) + - name: Build Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/oc_lettings_site:${{ github.sha }} . + docker tag ${{ secrets.DOCKER_USERNAME }}/oc_lettings_site:${{ github.sha }} ${{ secrets.DOCKER_USERNAME }}/oc_lettings_site:latest + + # 4. Push Docker images + - name: Push Docker image (SHA) + run: | + docker push ${{ secrets.DOCKER_USERNAME }}/oc_lettings_site:${{ github.sha }} + + - name: Push Docker image (latest) + run: | + docker push ${{ secrets.DOCKER_USERNAME }}/oc_lettings_site:latest + + deploy: + + needs: containerize + + if: github.ref == 'refs/heads/master' + + runs-on: ubuntu-latest + + #Trigger Render deployment + steps: + - 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..85542bd19f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ **/__pycache__ *.pyc venv +.idea/ +.env +*.log +db.sqlite3 +docs/build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..8312360b40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Base image +FROM python:3.10-slim + +# Prevent Python from buffering stdout/stderr +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Copy requirements first (cache optimization) +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Collect statics +RUN SECRET_KEY=dummy-secret-key \ + USE_SQLITE=true \ + python manage.py collectstatic --noinput + +# Expose port +EXPOSE 8000 + +# Run server with start.sh script +RUN chmod +x start.sh + +CMD ["./start.sh"] diff --git a/README.md b/README.md index c8547803f7..b62dd38aa7 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,168 @@ -## Résumé +# Orange County Lettings -Site web d'Orange County Lettings +## Présentation du projet -## Développement local +Orange County Lettings est une application web développée avec Django pour une start-up spécialisée dans la location de biens immobiliers aux États-Unis. + +Ce projet a consisté à améliorer une application existante en la restructurant autour d'une architecture Django modulaire et en mettant en place une chaîne d'intégration et de déploiement continus (CI/CD). + +La documentation complète du projet est disponible ici : + +## Fonctionnalités + +L'application permet de consulter les principales informations du service Orange County Lettings à travers une interface web développée avec Django. + +Les fonctionnalités disponibles sont les suivantes : + +- consultation des annonces immobilières ; +- consultation des profils utilisateurs ; +- accès à l'interface d'administration Django pour la gestion des données. + +## Architecture + +Le projet est organisé selon une architecture Django modulaire composée de trois applications principales : + +- **oc_lettings_site** : configuration générale du projet (settings, routage, serveur WSGI) ; +- **lettings** : gestion des annonces immobilières ; +- **profiles** : gestion des profils utilisateurs. + +Cette organisation permet de séparer les responsabilités de chaque composant et facilite la maintenance ainsi que l'évolution de l'application. + +L'application est conteneurisée avec Docker et déployée automatiquement via une pipeline CI/CD sur la plateforme Render. + +Pour une description détaillée de l'architecture, du déploiement et de l'infrastructure, consultez la documentation complète du projet. + +## Stack technique + +Le projet s'appuie sur les technologies suivantes : + +- **Langage :** Python 3.10 +- **Framework :** Django 3.0 +- **Bases de données :** SQLite (développement rapide), PostgreSQL (Docker et production) +- **Conteneurisation :** Docker, Docker Compose +- **CI/CD :** GitHub Actions +- **Tests et qualité :** pytest, pytest-django, coverage, flake8, black +- **Monitoring :** Sentry +- **Production :** Gunicorn, WhiteNoise, Render +- **Configuration :** python-dotenv + +## Utilisation en local ### Prérequis -- Compte GitHub avec accès en lecture à ce repository -- Git CLI -- SQLite3 CLI -- Interpréteur Python, version 3.6 ou supérieure +Pour exécuter le projet localement, les outils suivants sont nécessaires : -Dans le reste de la documentation sur le développement local, il est supposé que la commande `python` de votre OS shell exécute l'interpréteur Python ci-dessus (à moins qu'un environnement virtuel ne soit activé). +- Git ; +- Python 3.10 ou supérieur ; +- Docker et Docker Compose (optionnel, pour une exécution conteneurisée). -### macOS / Linux +### Installation -#### Cloner le repository +Clonez le dépôt puis installez les dépendances dans un environnement virtuel : -- `cd /path/to/put/project/in` -- `git clone https://github.com/OpenClassrooms-Student-Center/Python-OC-Lettings-FR.git` +```bash +git clone https://github.com/OpenClassrooms-Student-Center/Python-OC-Lettings-FR.git +cd Python-OC-Lettings-FR -#### Créer l'environnement virtuel +python -m venv venv +source venv/bin/activate # Linux / macOS +# ou +venv\Scripts\activate # Windows -- `cd /path/to/Python-OC-Lettings-FR` -- `python -m venv venv` -- `apt-get install python3-venv` (Si l'étape précédente comporte des erreurs avec un paquet non trouvé sur Ubuntu) -- Activer l'environnement `source venv/bin/activate` -- Confirmer que la commande `python` exécute l'interpréteur Python dans l'environnement virtuel -`which python` -- Confirmer que la version de l'interpréteur Python est la version 3.6 ou supérieure `python --version` -- Confirmer que la commande `pip` exécute l'exécutable pip dans l'environnement virtuel, `which pip` -- Pour désactiver l'environnement, `deactivate` +pip install -r requirements.txt +``` -#### Exécuter le site +Créez ensuite un fichier .env à la racine du projet et renseignez les variables d'environnement nécessaires. -- `cd /path/to/Python-OC-Lettings-FR` -- `source venv/bin/activate` -- `pip install --requirement requirements.txt` -- `python manage.py runserver` -- Aller sur `http://localhost:8000` dans un navigateur. -- Confirmer que le site fonctionne et qu'il est possible de naviguer (vous devriez voir plusieurs profils et locations). +### Lancement de l'application -#### Linting +#### Avec Django -- `cd /path/to/Python-OC-Lettings-FR` -- `source venv/bin/activate` -- `flake8` +```bash +python manage.py migrate +python manage.py runserver +``` -#### Tests unitaires +#### Avec Docker Compose -- `cd /path/to/Python-OC-Lettings-FR` -- `source venv/bin/activate` -- `pytest` +```bash +docker compose up --build +``` + +L'application est ensuite accessible à l'adresse : -#### Base de données +http://localhost:8000 + +Pour plus d'informations sur la configuration de l'environnement, les variables d'environnement ou l'utilisation de Docker Compose, consultez la documentation complète. + +## Tests et qualité du code + +Les principaux outils de qualité peuvent être exécutés depuis la racine du projet : + +```bash +flake8 # Analyse statique +pytest # Tests unitaires +pytest --cov # Couverture des tests +``` + +Ces vérifications sont également exécutées automatiquement par la pipeline CI/CD à chaque `push` et à chaque Pull Request. + +## Pipeline CI/CD + +Le projet utilise une pipeline GitHub Actions pour automatiser les contrôles qualité, la construction de l'image Docker et le déploiement en production. + +```text +Push / Pull Request + │ + ▼ +GitHub Actions + │ + ▼ +Job "compile" + │ + ├── Installation des dépendances + ├── Analyse statique (flake8) + └── Tests unitaires + couverture + │ + └──────────────► Validation réussie + │ + ▼ + (push sur master uniquement) + │ + ▼ + Job "containerize" + │ + ├── Build Docker + ├── Tag SHA + ├── Tag latest + └── Push DockerHub + │ + ▼ + Job "deploy" + │ + ▼ + Render +``` + +Les informations sensibles utilisées par la pipeline sont stockées dans les **GitHub Actions Secrets** et ne sont jamais versionnées dans le dépôt. + +## Déploiement + +L'application est déployée automatiquement sur **Render** à partir de la pipeline GitHub Actions. + +Lorsqu'un changement est fusionné sur la branche `master`, la pipeline : + +1. construit une image Docker ; +2. publie cette image sur Docker Hub ; +3. déclenche le déploiement sur Render via un Deploy Hook. + +Au démarrage du conteneur, le script `start.sh` applique automatiquement les migrations Django avant de lancer le serveur Gunicorn. + +L'application s'exécute en production avec une base de données PostgreSQL managée par Render. L'ensemble de la configuration est assuré au moyen de variables d'environnement et le service est vérifié automatiquement par le mécanisme de **Health Check** de Render. + +## Autres commandes utiles + +### Base de données - `cd /path/to/Python-OC-Lettings-FR` - Ouvrir une session shell `sqlite3` @@ -64,14 +173,22 @@ Dans le reste de la documentation sur le développement local, il est supposé q Python-OC-Lettings-FR_profile where favorite_city like 'B%';` - `.quit` pour quitter -#### Panel d'administration +### Panel d'administration - Aller sur `http://localhost:8000/admin` - Connectez-vous avec l'utilisateur `admin`, mot de passe `Abc1234!` -### Windows +## Liens utiles + +Les documentations officielles suivantes permettent d'approfondir les technologies utilisées dans ce projet : + +- GitHub : https://docs.github.com/fr +- Docker : https://docs.docker.com/ +- GitHub Actions : https://docs.github.com/fr/actions +- Render : https://render.com/docs + +## Auteur -Utilisation de PowerShell, comme ci-dessus sauf : +Jérémy Muller, étudiant en développement applicatif python chez OpenClassrooms. -- Pour activer l'environnement virtuel, `.\venv\Scripts\Activate.ps1` -- Remplacer `which ` par `(Get-Command ).Path` +GitHub : https://github.com/Jeremuller/ diff --git a/data.json b/data.json new file mode 100644 index 0000000000..b7c80eae1d --- /dev/null +++ b/data.json @@ -0,0 +1,585 @@ +[ +{ + "model": "lettings.address", + "pk": 1, + "fields": { + "number": 7217, + "street": "Bedford Street", + "city": "Brunswick", + "state": "GA", + "zip_code": 31525, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.address", + "pk": 2, + "fields": { + "number": 4, + "street": "Military Street", + "city": "Willoughby", + "state": "OH", + "zip_code": 44094, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.address", + "pk": 3, + "fields": { + "number": 340, + "street": "Wintergreen Avenue", + "city": "Newport News", + "state": "VA", + "zip_code": 23601, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.address", + "pk": 4, + "fields": { + "number": 9230, + "street": "E. Joy Ridge Street", + "city": "Marquette", + "state": "MI", + "zip_code": 49855, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.address", + "pk": 5, + "fields": { + "number": 9606, + "street": "Harvard Street", + "city": "Aliquippa", + "state": "PA", + "zip_code": 15001, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.address", + "pk": 6, + "fields": { + "number": 588, + "street": "Argyle Avenue", + "city": "East Meadow", + "state": "NY", + "zip_code": 11554, + "country_iso_code": "USA" + } +}, +{ + "model": "lettings.letting", + "pk": 1, + "fields": { + "title": "Joshua Tree Green Haus /w Hot Tub", + "address": 1 + } +}, +{ + "model": "lettings.letting", + "pk": 2, + "fields": { + "title": "Oceanview Retreat", + "address": 2 + } +}, +{ + "model": "lettings.letting", + "pk": 3, + "fields": { + "title": "'Silo Studio' Cottage", + "address": 3 + } +}, +{ + "model": "lettings.letting", + "pk": 4, + "fields": { + "title": "Pirates of the Caribbean Getaway", + "address": 4 + } +}, +{ + "model": "lettings.letting", + "pk": 5, + "fields": { + "title": "The Mushroom Dome Retreat & LAND of Paradise Suite", + "address": 5 + } +}, +{ + "model": "lettings.letting", + "pk": 6, + "fields": { + "title": "Underground Hygge", + "address": 6 + } +}, +{ + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$180000$p35UH2yYInHF$4LTphT2basUmvfLq+XHETVvKONZrWUsLfKu1K8/Hqdk=", + "last_login": "2026-04-04T12:22:01.195", + "is_superuser": true, + "username": "admin", + "first_name": "", + "last_name": "", + "email": "admin@email.com", + "is_staff": true, + "is_active": true, + "date_joined": "2020-06-14T09:41:15.326", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$180000$8ZKjEEdeYubZ$jq4T/Vaa2DWdAvzNys4ynNO6Wd/PsWe3dux20F7BGgQ=", + "last_login": null, + "is_superuser": false, + "username": "4meRomance", + "first_name": "John", + "last_name": "Rodriguez", + "email": "coemperor@famemma.net", + "is_staff": false, + "is_active": true, + "date_joined": "2020-06-14T09:44:05", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$180000$DdNkE39rolFF$nGmWZanXv4GlcTxtfUgc+MUIqBgDszAtvFfuFu538LQ=", + "last_login": null, + "is_superuser": false, + "username": "AirWow", + "first_name": "Ada", + "last_name": "Paul", + "email": "flocation.vam4@glendenningflowerdesign.com", + "is_staff": false, + "is_active": true, + "date_joined": "2020-06-14T09:44:45", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$180000$3VJdHtu39cbD$8qNVkvJ0KddsvfFueEm09Sg0LxgFievigmtAEb39paE=", + "last_login": null, + "is_superuser": false, + "username": "DavWin", + "first_name": "Cassandra", + "last_name": "Grahm", + "email": "5houssam.kessaiso@facpidif.ml", + "is_staff": false, + "is_active": true, + "date_joined": "2020-06-14T09:46:28", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 5, + "fields": { + "password": "pbkdf2_sha256$180000$zjnQu4LiqMAT$Qxom08ahzw11iPlX6kYyySa94yJXdjrptta6Qzx8HWE=", + "last_login": null, + "is_superuser": false, + "username": "HeadlinesGazer", + "first_name": "Jamie", + "last_name": "Lal", + "email": "jssssss33@acee9.live", + "is_staff": false, + "is_active": true, + "date_joined": "2020-06-14T09:47:21", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "sessions.session", + "pk": "6rmrxn3uts8m2ytfny39msvhhcr7x6ge", + "fields": { + "session_data": "YzFjNTIxZWFkZGRkMmE2Y2MwNDY2YWUzOTg1MGJlNTM1MTRiYjliNjp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI1NTMxMjkyZWYxMzBjMjBjNDMwOWE5YzEzMGJmMDYyZjlmN2Q0NTQxIn0=", + "expire_date": "2020-06-28T09:41:56.984" + } +}, +{ + "model": "sessions.session", + "pk": "r8q5klofqavm3xgkrokfb2k0ftuqg5qi", + "fields": { + "session_data": "YzFjNTIxZWFkZGRkMmE2Y2MwNDY2YWUzOTg1MGJlNTM1MTRiYjliNjp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI1NTMxMjkyZWYxMzBjMjBjNDMwOWE5YzEzMGJmMDYyZjlmN2Q0NTQxIn0=", + "expire_date": "2026-04-18T12:22:01.200" + } +}, +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2020-06-14T09:44:05.888", + "user": 1, + "content_type": 7, + "object_id": "2", + "object_repr": "4meRomance", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2020-06-14T09:44:45.294", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "AirWow", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2020-06-14T09:45:14.363", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "AirWow", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\", \"Last name\", \"Email address\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2020-06-14T09:45:57.716", + "user": 1, + "content_type": 7, + "object_id": "2", + "object_repr": "4meRomance", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\", \"Last name\", \"Email address\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2020-06-14T09:46:29.087", + "user": 1, + "content_type": 7, + "object_id": "4", + "object_repr": "DavWin", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2020-06-14T09:46:46.028", + "user": 1, + "content_type": 7, + "object_id": "4", + "object_repr": "DavWin", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\", \"Last name\", \"Email address\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2020-06-14T09:47:21.157", + "user": 1, + "content_type": 7, + "object_id": "5", + "object_repr": "HeadlinesGazer", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2020-06-14T09:47:39.497", + "user": 1, + "content_type": 7, + "object_id": "5", + "object_repr": "HeadlinesGazer", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\", \"Last name\", \"Email address\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2020-06-14T09:47:42.559", + "user": 1, + "content_type": 7, + "object_id": "5", + "object_repr": "HeadlinesGazer", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2020-06-14T09:49:23.116", + "user": 1, + "content_type": 1, + "object_id": "1", + "object_repr": "7217 Bedford Street", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2020-06-14T09:49:50.934", + "user": 1, + "content_type": 1, + "object_id": "2", + "object_repr": "4 Military Street", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2020-06-14T09:50:23.835", + "user": 1, + "content_type": 1, + "object_id": "3", + "object_repr": "340 Wintergreen Avenue", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2020-06-14T09:50:52.590", + "user": 1, + "content_type": 1, + "object_id": "4", + "object_repr": "9230 E. Joy Ridge Street", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2020-06-14T09:51:21.192", + "user": 1, + "content_type": 1, + "object_id": "5", + "object_repr": "9606 Harvard Street", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2020-06-14T09:51:46.901", + "user": 1, + "content_type": 1, + "object_id": "6", + "object_repr": "588 Argyle Avenue", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2020-06-14T09:52:18.053", + "user": 1, + "content_type": 3, + "object_id": "1", + "object_repr": "Joshua Tree Green Haus /w Hot Tub", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2020-06-14T09:52:32.121", + "user": 1, + "content_type": 3, + "object_id": "2", + "object_repr": "Oceanview Retreat", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2020-06-14T09:52:43.896", + "user": 1, + "content_type": 3, + "object_id": "3", + "object_repr": "'Silo Studio' Cottage", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2020-06-14T09:52:59.316", + "user": 1, + "content_type": 3, + "object_id": "4", + "object_repr": "Pirates of the Caribbean Getaway", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 20, + "fields": { + "action_time": "2020-06-14T09:53:41.990", + "user": 1, + "content_type": 3, + "object_id": "5", + "object_repr": "The Mushroom Dome Retreat & LAND of Paradise Suite", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 21, + "fields": { + "action_time": "2020-06-14T09:53:54.720", + "user": 1, + "content_type": 3, + "object_id": "6", + "object_repr": "Underground Hygge", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 22, + "fields": { + "action_time": "2020-06-14T09:54:29.736", + "user": 1, + "content_type": 2, + "object_id": "1", + "object_repr": "HeadlinesGazer", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 23, + "fields": { + "action_time": "2020-06-14T09:54:41.934", + "user": 1, + "content_type": 2, + "object_id": "2", + "object_repr": "DavWin", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 24, + "fields": { + "action_time": "2020-06-14T09:54:53.201", + "user": 1, + "content_type": 2, + "object_id": "3", + "object_repr": "AirWow", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 25, + "fields": { + "action_time": "2020-06-14T09:55:06.931", + "user": 1, + "content_type": 2, + "object_id": "4", + "object_repr": "4meRomance", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "profiles.profile", + "pk": 1, + "fields": { + "user": 5, + "favorite_city": "Buenos Aires" + } +}, +{ + "model": "profiles.profile", + "pk": 2, + "fields": { + "user": 4, + "favorite_city": "Barcelona" + } +}, +{ + "model": "profiles.profile", + "pk": 3, + "fields": { + "user": 3, + "favorite_city": "Budapest" + } +}, +{ + "model": "profiles.profile", + "pk": 4, + "fields": { + "user": 2, + "favorite_city": "Berlin" + } +} +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..81b9bc9c24 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + + db: + image: postgres:16 + container_name: oc_lettings_postgres + env_file: + - .env + volumes: + - oc_lettings_postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + web: + build: . + container_name: oc_lettings_web + env_file: + - .env + ports: + - "8000:8000" + depends_on: + - db + command: ./start.sh + +volumes: + oc_lettings_postgres_data: \ No newline at end of file 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/architecture.rst b/docs/source/architecture.rst new file mode 100644 index 0000000000..95bd9c766f --- /dev/null +++ b/docs/source/architecture.rst @@ -0,0 +1,55 @@ +Architecture +============ + +Orange County Lettings is organized as a modular Django project. Each application +is responsible for a specific functional domain, improving maintainability and +allowing independent evolution of the different components. + +Project structure +----------------- + +The project is composed of three main Django applications: + +- ``oc_lettings_site``: global project configuration, URL routing, WSGI application + and project settings; +- ``lettings``: management of property listings and associated data; +- ``profiles``: management of user profiles and user-related information. + +This separation follows Django's recommended application structure and keeps +business logic isolated from project configuration. + +Application architecture +------------------------ + +The application follows Django's Model-View-Template (MVT) architecture: + +- **Models** define the data structure and database interactions through Django ORM; +- **Views** handle HTTP requests, retrieve data and prepare responses; +- **Templates** provide the user interface rendered by the application. + +Request flow +------------ + +A typical request follows this workflow: + +1. The user sends an HTTP request. +2. Django resolves the URL through the project's routing configuration. +3. The corresponding view processes the request. +4. Models are used to retrieve or update data when required. +5. The view renders the appropriate template and returns an HTTP response. + +Deployment architecture +----------------------- + +The application is containerized using Docker and deployed automatically through a CI/CD +pipeline. + +The production architecture relies on: + +- Docker for application containerization; +- PostgreSQL as the production database; +- Gunicorn as the WSGI server; +- Render as the hosting platform. + +The detailed source code reference generated from docstrings is available in the +dedicated API reference sections of this documentation. \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000000..0e6de4dc74 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,43 @@ +# 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 + +import os +import sys +import django + +sys.path.insert(0, os.path.abspath("../..")) + +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", + "oc_lettings_site.settings", +) + +django.setup() + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Orange County Lettings' +copyright = '2026, Jeremy Muller' +author = 'Jeremy Muller' + +# -- 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 = 'en' + +# -- 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/database.rst b/docs/source/database.rst new file mode 100644 index 0000000000..483e8cb3a3 --- /dev/null +++ b/docs/source/database.rst @@ -0,0 +1,54 @@ +Database +======== + +The Orange County Lettings project uses a relational database system to store +application data. + +Django's Object-Relational Mapping (ORM) is used to interact with the database +through Python models instead of writing direct SQL queries. + +Database environments +--------------------- + +The project uses different database backends depending on the execution +environment: + +- **SQLite** is used for quickstart and automated tests. It provides a lightweight + database solution requiring no additional service configuration. +- **PostgreSQL** is used for Docker and production environments. It provides a + more robust and scalable database system suitable for deployment. + +The database engine is selected through environment configuration. + +Data models +----------- + +The application data is organized through Django models defined in each +application. + +Main entities include: + +- ``Letting``: represents a property listing; +- ``Address``: stores the location information associated with a letting; +- ``Profile``: extends Django's built-in authentication system by associating additional information with each user. + +The models are managed through Django migrations, which ensure that the +database schema remains synchronized with the application code. + +Database reference +------------------ + +The following sections are automatically generated from the project's model +docstrings using Sphinx ``autodoc``. + +Lettings models +~~~~~~~~~~~~~~~ + +.. automodule:: lettings.models + :members: Address, Letting + +Profiles models +~~~~~~~~~~~~~~~ + +.. automodule:: profiles.models + :members: Profile \ No newline at end of file diff --git a/docs/source/deployment.rst b/docs/source/deployment.rst new file mode 100644 index 0000000000..cfe2e4a5b5 --- /dev/null +++ b/docs/source/deployment.rst @@ -0,0 +1,52 @@ +Deployment +========== + +The Orange County Lettings application is deployed on Render using a +containerized production environment. + +Production architecture +----------------------- + +The production environment relies on: + +- Docker for application containerization; +- Render as hosting platform; +- PostgreSQL as persistent database; +- Gunicorn as WSGI server; +- WhiteNoise for static file serving. + +Deployment process +------------------ + +The deployment process is automated through the CI/CD pipeline. + +When changes are merged into the production branch, the pipeline: + +#. Builds a new Docker image. +#. Publishes the image to Docker Hub. +#. Triggers a deployment on Render using a Deploy Hook. +#. Starts the updated production container. + +The Render Deploy Hook allows the CI/CD pipeline to remotely trigger a new +deployment while keeping deployment credentials outside the source code. + +Application startup +------------------- + +When the container starts: + +#. The environment variables are loaded. +#. Database migrations are applied through ``start.sh``. +#. Gunicorn starts the Django application. +#. The application becomes available through the Render service. + +Configuration management +------------------------ + +Sensitive configuration values are provided through environment variables. + +The following elements are configured externally: + +- Django secret key; +- database credentials; +- deployment settings. diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000..e872b70f8a --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +Orange County Lettings documentation +==================================== + +Welcome to the technical documentation of the Orange County Lettings project. + +This documentation explains the project's architecture, installation process, integration and deployment pipeline and application components. + +Use the navigation menu below to browse each section. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + introduction + installation + technologies + architecture + database + views_and_endpoints + use_cases + pipeline + deployment diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000000..93ca78b210 --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,136 @@ +Installation +============ + +This section explains how to install and configure the Orange County Lettings project in a local environment or with Docker. + +It complements the README by providing a more detailed setup procedure and environment configuration. + +The project supports two installation modes: + +- local Python environment (development) +- Docker-based environment (production-like) + +Prerequisites +------------- + +Ensure the following tools are installed: + +- Git +- Python 3.10+ +- Docker and Docker Compose (optional but recommended) + +Project cloning +--------------- + +Clone the repository: + +.. code-block:: bash + + git clone https://github.com/OpenClassrooms-Student-Center/Python-OC-Lettings-FR.git + cd Python-OC-Lettings-FR + +Local development setup +----------------------- + +This setup is intended for development without containers. + +Virtual environment +~~~~~~~~~~~~~~~~~~~ + +Create and activate a virtual environment to isolate dependencies: + +.. code-block:: bash + + python -m venv venv + + # Linux / macOS + source venv/bin/activate + + # Windows + venv\Scripts\activate + +Dependencies installation +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Install project dependencies: + +.. code-block:: bash + + pip install -r requirements.txt + +This includes Django, testing tools, code quality utilities, and deployment dependencies. + +Environment configuration +------------------------- + +The application uses environment variables for configuration. + +Create a `.env` file at the project root. + +Required variables: + +- SECRET_KEY +- DEBUG +- USE_SQLITE +- SENTRY_DSN (optional) + +For PostgreSQL environments: + +- POSTGRES_DB +- POSTGRES_USER +- POSTGRES_PASSWORD +- POSTGRES_HOST +- POSTGRES_PORT + +Database setup +-------------- + +Apply database migrations before starting the application: + +.. code-block:: bash + + python manage.py migrate + +Run development server +---------------------- + +.. code-block:: bash + + python manage.py runserver + +Access the application at: + +http://localhost:8000 + +Docker setup +------------- + +A Docker Compose configuration is provided to run the full application stack with a single command. + +This setup is recommended for: + +- production-like execution +- reproducibility +- avoiding local dependency issues + +Start containers: + +.. code-block:: bash + + docker compose up --build + +Container initialization +------------------------ + +On startup, the container automatically: + +- applies database migrations +- starts the Gunicorn server + +This is handled by the `start.sh` script and ensures the database schema is always up to date before serving requests. + +Environment differences +----------------------- + +- Local setup: SQLite (lightweight, portability) +- Docker setup: PostgreSQL (production-like) diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000000..a221c2ef2e --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,47 @@ +Introduction +============ + +Project overview +---------------- + +Orange County Lettings is a Django-based web application that allows users to browse property listings and view detailed information about associated user profiles. + +The project is designed with a modular architecture to ensure a clear separation of concerns between its different components (profiles, lettings, main site, etc.). + +Development context +------------------- + +This project was developed as part of the Python Application Developer program at OpenClassrooms. + +It serves as a practical implementation of core software engineering concepts, including: + +- Django web application development +- modular architecture design +- reproducible development environments +- CI/CD pipelines and automation + +Technical objectives +-------------------- + +Beyond functional requirements, this project demonstrates the ability to manage a complete software development lifecycle: + +- Git and GitHub version control +- code quality analysis with linting tools +- unit testing with pytest +- test coverage analysis +- automated workflows using GitHub Actions +- containerization with Docker +- automated deployment to production environments + +High-level architecture +----------------------- + +The application consists of several core components: + +- a Django web application handling business logic and the user interface +- a relational database (SQLite in quickstart/testing, PostgreSQL in development and production) +- a CI/CD pipeline for automated testing and deployment +- a Docker-based deployment infrastructure hosted on Render + +The following sections of this documentation provide more details on +installation, architecture, application components, use cases and deployment. \ No newline at end of file diff --git a/docs/source/pipeline.rst b/docs/source/pipeline.rst new file mode 100644 index 0000000000..e773e93381 --- /dev/null +++ b/docs/source/pipeline.rst @@ -0,0 +1,126 @@ +Pipeline +======== + +The Orange County Lettings project uses a continuous integration and deployment +pipeline based on GitHub Actions. + +The pipeline automates code validation, Docker image creation, and production +deployment in order to ensure that only validated changes can reach the +production environment. + +Pipeline workflow +----------------- + +The global workflow follows these steps: + +#. A developer pushes changes or creates a Pull Request on GitHub. +#. The ``compile`` job validates the source code quality and runs automated tests. +#. When validation succeeds on the production branch, the ``containerize`` job + builds and publishes the Docker image. +#. The ``deploy`` job triggers the production deployment on Render. + +:: + + Developer + | + v + GitHub repository + | + v + GitHub Actions + | + +----------------+ + | compile | + |----------------| + | Dependencies | + | flake8 | + | pytest | + | coverage | + +----------------+ + | + v + (master branch only) + | + v + +----------------+ + | containerize | + |----------------| + | Docker build | + | Docker Hub | + +----------------+ + | + v + +----------------+ + | deploy | + |----------------| + | Render Hook | + +----------------+ + + +Compile job +----------- + +The ``compile`` job runs for every push and Pull Request. + +Its purpose is to validate that the application remains stable before any +deployment operation. + +The job performs: + +- installation of Python dependencies; +- static code analysis using ``flake8``; +- execution of automated tests using ``pytest``; +- measurement of test coverage. + +The application uses a minimum coverage threshold to prevent significant +regressions from being introduced. + +Containerize job +---------------- + +The ``containerize`` job runs only after a successful ``compile`` job and only +for changes merged into the production branch. + +It is responsible for creating and publishing the production Docker image. + +The job performs: + +- Docker image build; +- image tagging using the Git commit SHA; +- creation of a ``latest`` image tag; +- publication to Docker Hub. + +Using immutable SHA-based tags allows each production image to be uniquely +identified and traced back to a specific source revision. + +Deploy job +---------- + +The ``deploy`` job is executed after the Docker image has been successfully +published. + +It triggers a Render deployment through a Deploy Hook. + +This mechanism allows GitHub Actions to remotely start a new production +deployment without storing deployment credentials inside the repository. + +Monitoring and logging +---------------------- + +The application integrates Sentry to monitor runtime behavior and production +issues. + +The monitoring strategy relies on three levels of information: + +- **Logs**: general application events and informational messages used to + understand normal application activity; +- **Warnings**: abnormal situations that do not prevent the application from + running but may require attention; +- **Errors**: application failures requiring investigation, including server + errors such as HTTP 500 responses. + +HTTP 404 responses are also monitored to help identify invalid routes or +unexpected user navigation patterns. + +This monitoring approach provides visibility into application health after +deployment and helps detect issues that are not visible during automated tests. \ No newline at end of file diff --git a/docs/source/technologies.rst b/docs/source/technologies.rst new file mode 100644 index 0000000000..1deb97e63e --- /dev/null +++ b/docs/source/technologies.rst @@ -0,0 +1,48 @@ +Technologies +============ + +The Orange County Lettings project is built on a Python web stack designed to improve maintainability, reproducibility, and deployment automation. + +Backend +------- + +- Python 3.10: main programming language +- Django 3.0: web framework used for application structure, routing, and ORM + +Databases +---------- + +- SQLite: used for CI unit tests and fast setup +- PostgreSQL: used in Docker and production environments + +Containerization +----------------- + +- Docker: container runtime for the application +- Docker Compose: configuration and orchestration of the application services + +CI/CD +----- + +- GitHub Actions: automated workflows for testing, linting, and deployment + +Testing and code quality +------------------------ + +- pytest / pytest-django: unit and integration testing +- coverage: test coverage measurement +- flake8: linting for code style and quality +- black: code formatting + +Production stack +---------------- + +- Gunicorn: WSGI HTTP server for running Django in production +- WhiteNoise: static file serving without external storage +- Render: cloud platform used for hosting and deployment + +Monitoring and configuration +----------------------------- + +- Sentry: error tracking and monitoring +- python-dotenv: environment variable management via `.env` files \ No newline at end of file diff --git a/docs/source/use_cases.rst b/docs/source/use_cases.rst new file mode 100644 index 0000000000..43a0bcac93 --- /dev/null +++ b/docs/source/use_cases.rst @@ -0,0 +1,63 @@ +Use Cases +========= + +This section describes the main use cases supported by the Orange County +Lettings application. + +Functional use cases +-------------------- + +Browse rental listings +~~~~~~~~~~~~~~~~~~~~~~ + +Typical workflow: + +#. Open the application homepage. +#. Navigate to the lettings section. +#. Browse the list of available properties. +#. Select a listing to view its details. + +View a user profile +~~~~~~~~~~~~~~~~~~~ + +Users can access the details of a specific profile, including the associated +favorite city. + +Typical workflow: + +#. Open the profiles section. +#. Browse the available user profiles. +#. Select a profile from the list. +#. View the profile details. + +Manage application data +~~~~~~~~~~~~~~~~~~~~~~~ + +Administrators can manage lettings, addresses and user profiles through +the Django administration interface. + +Typical tasks include: + +- creating or updating lettings; +- managing addresses; +- editing user profiles; +- removing obsolete records. + +Development use case +-------------------- + +Contribute to the project +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Developers can contribute to the project by working in a reproducible +development environment, running automated quality checks and tests, and +submitting changes through the Git workflow. + + +A typical contribution follows the project's development workflow: + +#. Create a feature branch. +#. Implement the requested changes. +#. Push the branch to GitHub. +#. Trigger the CI pipeline to validate the contribution. +#. Merge and deploy the application. \ No newline at end of file diff --git a/docs/source/views_and_endpoints.rst b/docs/source/views_and_endpoints.rst new file mode 100644 index 0000000000..d5b63eebab --- /dev/null +++ b/docs/source/views_and_endpoints.rst @@ -0,0 +1,46 @@ +Views and endpoints +=================== + +The application exposes web routes through Django URL routing. +Each route is associated with a view function responsible for processing +requests and returning the appropriate response. + +Request handling +---------------- + +Django processes incoming requests through the following workflow: + +1. The URL dispatcher matches the requested path with a configured route. +2. The associated view retrieves or processes the required data using models. +3. The view renders a template with the provided context. +4. Django returns the generated HTTP response to the client. + +URL organization +---------------- + +Each Django application defines its own URL configuration: + +- ``oc_lettings_site``: global URL configuration; +- ``lettings`` manages property listing pages; +- ``profiles`` manages user profile pages. + +The application namespaces allow URL names to remain isolated between +different components. + +Views reference +--------------- + +The following sections are automatically generated from the project's +view docstrings using Sphinx ``autodoc``. + +Lettings views +~~~~~~~~~~~~~~ + +.. automodule:: lettings.views + :members: + +Profiles views +~~~~~~~~~~~~~~ + +.. automodule:: profiles.views + :members: \ No newline at end of file diff --git a/global_templates/404.html b/global_templates/404.html new file mode 100644 index 0000000000..2537cc23c5 --- /dev/null +++ b/global_templates/404.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Page not found{% endblock %} + +{% block content %} + +
+

404 - Page not found

+ +

+ Sorry, the page you are looking for does not exist. + Maybe you were trying to access something that does not exist in our database? +

+ + + Return to home + +
+ +{% endblock %} \ No newline at end of file diff --git a/global_templates/500.html b/global_templates/500.html new file mode 100644 index 0000000000..2790a0c469 --- /dev/null +++ b/global_templates/500.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Server error{% endblock %} + +{% block content %} + +
+

500 - Internal server error

+ +

+ Something went wrong on our side. +

+ +

+ Our team has been notified and is working on it. +

+ + + Return to home + +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/global_templates/base.html similarity index 96% rename from templates/base.html rename to global_templates/base.html index ab7addba01..51c8b727be 100644 --- a/templates/base.html +++ b/global_templates/base.html @@ -24,10 +24,10 @@
Logo Orange County Lettings
- + Profiles - + Lettings
diff --git a/templates/index.html b/global_templates/index.html similarity index 82% rename from templates/index.html rename to global_templates/index.html index 71a8e61a46..34cb562c7c 100644 --- a/templates/index.html +++ b/global_templates/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Holiday Homes{% endblock title %} +{% block title %}Holiday Homes V2{% endblock title %} {% block content %} @@ -14,10 +14,10 @@

Welcome to Holiday Homes

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..5cf0fd0168 --- /dev/null +++ b/lettings/admin.py @@ -0,0 +1,37 @@ +""" +Admin configuration for the lettings application. + +This module registers the Letting and Address models +with the Django admin interface, allowing administrators +to manage lettings and related address data through +the built-in Django admin site. +""" + +from django.contrib import admin + +from .models import Letting +from .models import Address + + +@admin.register(Letting) +class LettingAdmin(admin.ModelAdmin): + """ + Admin configuration for the Letting model. + + This class enables the management of Letting instances + within the Django admin interface. + """ + + pass + + +@admin.register(Address) +class AddressAdmin(admin.ModelAdmin): + """ + Admin configuration for the Address model. + + This class enables the management of Address instances + within the Django admin interface. + """ + + pass diff --git a/lettings/apps.py b/lettings/apps.py new file mode 100644 index 0000000000..8eeb0d1cde --- /dev/null +++ b/lettings/apps.py @@ -0,0 +1,21 @@ +""" +Application configuration for the lettings app. + +This module defines the configuration class used by Django +to register and initialize the lettings application within +the project. +""" + +from django.apps import AppConfig + + +class LettingsConfig(AppConfig): + """ + Configuration class for the lettings application. + + This class specifies the application name and allows + future customization of application-level behavior + such as signal registration or startup logic. + """ + + name = "lettings" diff --git a/lettings/conftest.py b/lettings/conftest.py new file mode 100644 index 0000000000..3199fdac82 --- /dev/null +++ b/lettings/conftest.py @@ -0,0 +1,69 @@ +# conftest.py +""" +Central test configuration for the Django OC Lettings project. + +This module defines reusable pytest fixtures for testing the lettings +application. Fixtures provide pre-created instances of models such as +Address and Letting, which can be injected into unit and integration +tests to ensure consistent and isolated test environments. + +Fixtures: + - address: Creates a sample Address instance. + - letting: Creates a sample Letting instance linked to an Address. +""" + +import pytest +from lettings.models import Address, Letting + + +@pytest.fixture +def address(): + """ + Fixture that creates a sample Address instance. + + The Address instance represents a physical location with + predefined fields suitable for testing purposes. The object + is saved in the test database and is available for injection + into test functions. + + Returns: + Address: A saved Address instance with sample data. + + Example usage in a test function: + def test_address_str(address): + assert str(address) == "123 Main Street" + """ + return Address.objects.create( + number=123, + street="Main Street", + city="Los Angeles", + state="CA", + zip_code=90001, + country_iso_code="USA" + ) + + +@pytest.fixture +def letting(address): + """ + Fixture that creates a sample Letting instance linked to an Address. + + The Letting instance represents a rental property associated + with the provided Address fixture. It is saved in the test + database and can be injected into test functions to test + model behavior, view responses, and URL routing. + + Args: + address (Address): A fixture providing an Address instance. + + Returns: + Letting: A saved Letting instance linked to the provided address. + + Example usage in a test function: + def test_letting_str(letting): + assert str(letting) == "Beautiful Apartment" + """ + return Letting.objects.create( + title="Beautiful Apartment", + address=address + ) diff --git a/lettings/migrations/0001_initial.py b/lettings/migrations/0001_initial.py new file mode 100644 index 0000000000..e44c28e987 --- /dev/null +++ b/lettings/migrations/0001_initial.py @@ -0,0 +1,12 @@ +# Generated by Django 3.0 on 2026-02-01 10:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + ] diff --git a/lettings/migrations/0002_address_letting.py b/lettings/migrations/0002_address_letting.py new file mode 100644 index 0000000000..588784c641 --- /dev/null +++ b/lettings/migrations/0002_address_letting.py @@ -0,0 +1,78 @@ +# Generated by Django 3.0 on 2026-02-05 09:49 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("lettings", "0001_initial"), + ] + + 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)] + ), + ), + ], + options={ + "db_table": "oc_lettings_site_address", + }, + ), + 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" + ), + ), + ], + options={ + "db_table": "oc_lettings_site_letting", + }, + ), + ] diff --git a/lettings/migrations/0003_auto_20260205_1103.py b/lettings/migrations/0003_auto_20260205_1103.py new file mode 100644 index 0000000000..b607397132 --- /dev/null +++ b/lettings/migrations/0003_auto_20260205_1103.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0 on 2026-02-05 10:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('lettings', '0002_address_letting'), + ] + + operations = [ + migrations.AlterModelTable( + name='address', + table='lettings_address', + ), + migrations.AlterModelTable( + name='letting', + table='lettings_letting', + ), + ] 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..0fecba9664 --- /dev/null +++ b/lettings/models.py @@ -0,0 +1,79 @@ +""" +Database models for the ``lettings`` application. + +This module defines the data models used to represent rental properties +and their associated addresses. +""" + +from django.db import models +from django.core.validators import MaxValueValidator, MinLengthValidator + + +class Address(models.Model): + """ + Represent the physical location of a rental property. + + This model stores the address associated with a letting and + enforces basic validation rules on its fields to ensure data + consistency. + """ + + 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) -> str: + """ + Return a readable representation of the address. + + :returns: Street number followed by the street name. + :rtype: str + """ + return f"{self.number} {self.street}" + + class Meta: + """ + Define metadata associated with the ``Address`` model. + + The explicit database table name preserves compatibility with the + original project schema. + """ + + db_table = "lettings_address" + verbose_name = "Address" + verbose_name_plural = "Addresses" + + +class Letting(models.Model): + """ + Represent a rental property available through the application. + + Each letting is associated with a unique ``Address`` instance, + allowing the application to separate property information from + location data. + """ + + title = models.CharField(max_length=256) + address = models.OneToOneField(Address, on_delete=models.CASCADE) + + def __str__(self): + """ + Return the title of the letting. + + :returns: Letting title. + :rtype: str + """ + return self.title + + class Meta: + """ + Define metadata associated with the ``Letting`` model. + + The explicit database table name preserves compatibility with the + original project schema. + """ + + db_table = "lettings_letting" 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..ee6db103ab 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 94% rename from templates/letting.html rename to lettings/templates/lettings/letting.html index 7e5f3a73fd..cbaf2690d5 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..0a48fe477e --- /dev/null +++ b/lettings/tests.py @@ -0,0 +1,627 @@ +""" +Unit tests for the lettings.models module. + +This file contains tests for the Address and Letting models, +ensuring that model fields, string representations, and +basic validations work correctly. + +Fixtures: + - address: Pre-created Address instance for tests. +""" + +import pytest +from django.urls import reverse, resolve +from lettings import views + + +@pytest.mark.django_db +def test_address_str(address): + """ + Test the string representation of the Address model. + + The __str__ method should return a formatted string combining + the street number and the street name. + + Args: + address (Address): Fixture providing a sample Address instance. + """ + assert str(address) == "123 Main Street" + + +@pytest.mark.django_db +def test_address_fields(address): + """ + Test that the Address fields are correctly set from the fixture. + + Args: + address (Address): Fixture providing a sample Address instance. + """ + assert address.number == 123 + assert address.street == "Main Street" + assert address.city == "Los Angeles" + assert address.state == "CA" + assert address.zip_code == 90001 + assert address.country_iso_code == "USA" + + +""" +Unit tests for the Letting model in lettings.models. + +This file contains tests for the Address and Letting models, +ensuring that model fields, string representations, and +one-to-one relationships work correctly. +""" + + +@pytest.mark.django_db +def test_letting_str(letting): + """ + Test the string representation of the Letting model. + + The __str__ method should return the title of the letting. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + assert str(letting) == "Beautiful Apartment" + + +@pytest.mark.django_db +def test_letting_fields(letting, address): + """ + Test that the Letting fields are correctly set and linked to Address. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + address (Address): Fixture providing a sample Address instance. + """ + assert letting.title == "Beautiful Apartment" + # Check the one-to-one relationship + assert letting.address == address + # Confirm related data + assert letting.address.street == "Main Street" + assert letting.address.city == "Los Angeles" + + +""" +URL tests for the lettings application. + +This module verifies that URL patterns defined in lettings.urls +correctly resolve to their associated view functions and that +named routes generate the expected paths. + +The following aspects are tested: +- URL reversing using Django's reverse function +- URL resolution using Django's resolve function +- HTTP response status codes for defined routes +""" + + +@pytest.mark.django_db +def test_lettings_index_url_reverse(): + """ + Test that the lettings index URL is correctly reversed. + + Ensures that the named route 'lettings:lettings_index' + generates the expected URL path. + """ + url = reverse("lettings:lettings_index") + assert url == "/lettings/" + + +@pytest.mark.django_db +def test_letting_detail_url_reverse(letting): + """ + Test that the letting detail URL is correctly reversed + with a valid letting ID. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + url = reverse("lettings:letting", args=[letting.id]) + assert url == f"/lettings/{letting.id}/" + + +@pytest.mark.django_db +def test_lettings_index_url_resolves(): + """ + Test that the lettings index URL resolves + to the correct view function. + """ + resolver = resolve("/lettings/") + assert resolver.func == views.lettings_index + + +@pytest.mark.django_db +def test_letting_detail_url_resolves(letting): + """ + Test that the letting detail URL resolves + to the correct view function. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + resolver = resolve(f"/lettings/{letting.id}/") + assert resolver.func == views.letting + + +@pytest.mark.django_db +def test_lettings_index_http_response(client): + """ + Integration test ensuring that the lettings index + URL returns an HTTP 200 response. + """ + response = client.get(reverse("lettings:lettings_index")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_letting_detail_http_response(client, letting): + """ + Integration test ensuring that the letting detail + URL returns an HTTP 200 response for a valid ID. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + response = client.get(reverse("lettings:letting", args=[letting.id])) + assert response.status_code == 200 + + +""" +View tests for the lettings application. + +This module verifies that view functions defined in lettings.views +correctly render templates, return expected HTTP responses, +and provide appropriate context data. + +The following views are tested: +- lettings_index +- letting +""" + + +@pytest.mark.django_db +def test_lettings_index_view_status_code(client): + """ + Test that the lettings_index view returns HTTP 200. + """ + response = client.get(reverse("lettings:lettings_index")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_lettings_index_view_template(client): + """ + Test that the correct template is used + for the lettings_index view. + """ + response = client.get(reverse("lettings:lettings_index")) + assert "lettings/index.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_lettings_index_context(client, letting): + """ + Test that the lettings_index view provides + the correct context data. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + response = client.get(reverse("lettings:lettings_index")) + + assert "lettings_list" in response.context + assert letting in response.context["lettings_list"] + + +@pytest.mark.django_db +def test_letting_detail_view_status_code(client, letting): + """ + Test that the letting detail view returns HTTP 200 + for a valid letting ID. + """ + response = client.get(reverse("lettings:letting", args=[letting.id])) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_letting_detail_view_template(client, letting): + """ + Test that the correct template is used + for the letting detail view. + """ + response = client.get(reverse("lettings:letting", args=[letting.id])) + assert "lettings/letting.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_letting_detail_context(client, letting): + """ + Test that the letting detail view provides + the correct context variables. + + Args: + letting (Letting): Fixture providing a sample Letting instance. + """ + response = client.get(reverse("lettings:letting", args=[letting.id])) + + assert response.context["title"] == letting.title + assert response.context["address"] == letting.address + + +@pytest.mark.django_db +def test_letting_detail_invalid_id_returns_404(client): + """ + Ensure that requesting a non-existing letting returns HTTP 404. + + The view now uses get_object_or_404, which should return a + 404 response instead of raising an exception. + """ + response = client.get(reverse("lettings:letting", args=[9999])) + + assert response.status_code == 404 + + +""" +Integration tests for the `lettings` Django application. + +These tests validate the behavior of the application by exercising +the full request/response cycle of the Django stack: + + URL routing → View execution → Database access → Template rendering → HTTP response + +Unlike unit tests that focus on isolated components (models, views, or URLs), +integration tests ensure that multiple layers of the application work together +correctly when accessed through real HTTP requests. + +The following scenarios are covered: + +Happy paths: + - Accessing the lettings index page successfully + - Displaying existing lettings in the index page + - Navigating to the detail page of a specific letting + - Displaying letting address information correctly + +Edge cases: + - Rendering the index page when no lettings exist + +Sad paths (TDD approach): + - Requesting a letting that does not exist should return HTTP 404 + +Some tests intentionally document expected future behavior such as +proper 404 error handling. These tests may initially fail until the +application implements the appropriate error management. + +This approach follows Test-Driven Development (TDD) principles. +""" + + +@pytest.mark.django_db +def test_lettings_index_page_accessible(client): + """ + Verify that the lettings index page is accessible via HTTP. + + This test ensures that the URL associated with the lettings index + view is correctly configured and returns a valid HTTP response. + + The test validates that: + - The URL can be resolved using Django's reverse function + - The HTTP response status code is 200 (OK) + - The correct template is used to render the page + + This confirms that URL routing, view execution, and template + rendering work correctly together. + """ + url = reverse("lettings:lettings_index") + + response = client.get(url) + + assert response.status_code == 200 + assert "lettings/index.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_lettings_index_displays_existing_lettings(client, letting): + """ + Verify that existing lettings are displayed on the index page. + + This test creates a Letting instance using a fixture and ensures + that the index page correctly retrieves and renders the letting + information. + + The test validates that: + - The letting instance is retrieved from the database + - The letting title appears in the rendered HTML response + + This confirms that the view properly interacts with the database + and passes the expected context data to the template. + """ + url = reverse("lettings:lettings_index") + + response = client.get(url) + + content = response.content.decode() + + assert letting.title in content + + +@pytest.mark.django_db +def test_lettings_index_handles_empty_dataset(client): + """ + Verify the behavior of the index page when no lettings exist. + + When the database contains no Letting instances, the template + should display a fallback message informing the user that + no lettings are available. + + The test validates that: + - The page still renders successfully + - The appropriate message appears in the response + + This ensures the application handles empty datasets gracefully. + """ + url = reverse("lettings:lettings_index") + + response = client.get(url) + + content = response.content.decode() + + assert response.status_code == 200 + assert "No lettings are available." in content + + +@pytest.mark.django_db +def test_letting_detail_page_accessible(client, letting): + """ + Verify that a letting detail page can be accessed successfully. + + This test ensures that a valid letting ID correctly resolves + to the letting detail view and that the page renders without errors. + + The test validates that: + - The correct URL is generated using reverse() + - The view retrieves the letting from the database + - The correct template is used to render the page + - The HTTP response status is 200 (OK) + + This confirms the correct integration of URL routing, + database access, and template rendering. + """ + url = reverse("lettings:letting", args=[letting.id]) + + response = client.get(url) + + assert response.status_code == 200 + assert "lettings/letting.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_letting_detail_displays_address_information(client, letting): + """ + Verify that the letting detail page displays address information. + + This test ensures that the address fields associated with the + letting instance are correctly passed to the template and rendered + in the final HTML output. + + The test checks that the response contains: + - the letting title + - the street name + - the city name + + This confirms that the relationship between Letting and Address + models is correctly handled and displayed by the view and template. + """ + url = reverse("lettings:letting", args=[letting.id]) + + response = client.get(url) + + content = response.content.decode() + + assert letting.title in content + assert letting.address.street in content + assert letting.address.city in content + + +@pytest.mark.django_db +def test_letting_detail_returns_404_for_unknown_letting(client): + """ + Verify that requesting a non-existent letting returns HTTP 404. + + This test represents the expected behavior for the application + when a user attempts to access a letting that does not exist + in the database. + + According to REST and Django best practices, the application + should return an HTTP 404 (Not Found) response instead of + raising an unhandled exception. + + This test follows a Test-Driven Development (TDD) approach: + - It defines the expected behavior first + - The test may initially fail if the view does not yet + implement proper 404 handling + + The test will pass once the view uses Django's `get_object_or_404` + helper or equivalent error handling. + """ + url = reverse("lettings:letting", args=[9999]) + + response = client.get(url) + + assert response.status_code == 404 + assert "Sorry, the page you are looking for does not exist." in response.content.decode() + + +""" +Integration tests for the main Django project application `oc_lettings_site`. + +These tests validate the behavior of the project's root-level views and +their integration with the rest of the Django application stack. + +Unlike unit tests that verify isolated components, these tests exercise +the full request-response lifecycle: + + URL routing → View execution → Template rendering → HTTP response + +The main goal of these tests is to ensure that the project’s entry point +(the home page) behaves correctly and provides valid navigation to the +different Django applications included in the project. + +The following scenarios are covered: + +Happy paths: + - Accessing the home page successfully + - Rendering the correct template for the home page + - Displaying navigation links to the lettings and profiles sections + +Integration validation: + - Ensuring the navigation links resolve to valid URLs + - Verifying that cross-application routing is correctly configured + +Sad paths (TDD preparation): + - Documenting expected 404 behavior for unknown routes + +Some tests may initially fail if global error handling (404 / 500 pages) +is not yet implemented. This follows a Test-Driven Development (TDD) +approach where expected behaviors are specified before implementation. +""" + + +@pytest.mark.django_db +def test_homepage_accessible(client): + """ + Verify that the project home page is accessible. + + This test ensures that the root URL of the application resolves + correctly to the index view and returns a valid HTTP response. + + The test validates that: + - Django can resolve the URL using reverse() + - The HTTP response status code is 200 (OK) + + This confirms that the project's main entry point is correctly + configured and accessible to users. + """ + url = reverse("index") + + response = client.get(url) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_homepage_uses_correct_template(client): + """ + Verify that the home page uses the correct template. + + The index view should render the 'index.html' template. This test + ensures that the template rendering layer is correctly connected + to the view. + + This confirms the integration between: + - URL routing + - view execution + - template rendering + """ + url = reverse("index") + + response = client.get(url) + + templates = [t.name for t in response.templates] + + assert "index.html" in templates + + +@pytest.mark.django_db +def test_homepage_displays_navigation_links(client): + """ + Verify that the home page displays navigation links to the main + sections of the application. + + The index page should contain links allowing users to navigate + to both the lettings and profiles sections. + + This test validates that: + - the link to the lettings index page is present + - the link to the profiles index page is present + + This confirms that the template correctly integrates URL + resolution using Django's `{% url %}` template tag. + """ + url = reverse("index") + + response = client.get(url) + + content = response.content.decode() + + lettings_url = reverse("lettings:lettings_index") + profiles_url = reverse("profiles:profiles_index") + + assert lettings_url in content + assert profiles_url in content + + +@pytest.mark.django_db +def test_navigation_to_lettings_page(client): + """ + Verify that the lettings section is reachable from the project. + + This test ensures that the root URL configuration correctly + includes the URL patterns defined in the `lettings` application. + + The test validates that: + - the lettings index URL resolves correctly + - accessing the page returns HTTP 200 + + This confirms that cross-application URL inclusion works as expected. + """ + url = reverse("lettings:lettings_index") + + response = client.get(url) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_navigation_to_profiles_page(client): + """ + Verify that the profiles section is reachable from the project. + + This test ensures that the root URL configuration correctly + includes the URL patterns defined in the `profiles` application. + + The test validates that: + - the profiles index URL resolves correctly + - accessing the page returns HTTP 200 + + This confirms that cross-application routing is properly configured. + """ + url = reverse("profiles:profiles_index") + + response = client.get(url) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_unknown_url_returns_404(client): + """ + Verify that requesting an unknown URL returns HTTP 404. + + According to standard web application behavior, when a user + requests a route that does not exist, the server should return + a 404 (Not Found) response. + + This test documents the expected behavior for invalid routes. + + It also prepares the test suite for future implementation of + custom 404 error pages within the project. + + This test follows a Test-Driven Development (TDD) approach and + may evolve once dedicated error handlers are implemented. + """ + response = client.get("/this-page-does-not-exist/") + + assert response.status_code == 404 + assert "Sorry, the page you are looking for does not exist." in response.content.decode() diff --git a/lettings/urls.py b/lettings/urls.py new file mode 100644 index 0000000000..1c3a30a495 --- /dev/null +++ b/lettings/urls.py @@ -0,0 +1,20 @@ +""" +URL configuration for the ``lettings`` application. + +This module maps URL patterns to their corresponding view functions +and defines the namespace used for URL resolution. +""" + +from django.urls import path + +from . import views + +app_name = "lettings" +urlpatterns = [ + # Root path: displays the list of all available lettings + path("", views.lettings_index, name="lettings_index"), + + # Dynamic route: displays details for a specific letting + # identified by its primary key (letting_id) + path("/", views.letting, name="letting"), +] diff --git a/lettings/views.py b/lettings/views.py new file mode 100644 index 0000000000..ff08775cea --- /dev/null +++ b/lettings/views.py @@ -0,0 +1,72 @@ +""" +Views for the ``lettings`` application. + +This module provides the views responsible for displaying the list +of available lettings and the details of a specific letting. +""" + +from django.shortcuts import render, get_object_or_404 +from django.http import HttpRequest, HttpResponse +from .models import Letting + +import logging + +logger = logging.getLogger(__name__) + + +def lettings_index(request: HttpRequest) -> HttpResponse: + """ + Render the page listing all available lettings. + + This view retrieves all lettings from the database and displays them + on the lettings index page. + + An informational log entry is generated whenever the page is accessed. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :returns: Rendered lettings index page. + :rtype: HttpResponse + """ + logger.info( + "Lettings list accessed", + extra={ + "path": request.path, + "method": request.method, + }, + ) + lettings_list = Letting.objects.all() + context = {"lettings_list": lettings_list} + return render(request, "lettings/index.html", context) + + +def letting(request: HttpRequest, letting_id: int) -> HttpResponse: + """ + Render the page displaying details of a specific letting. + + This view retrieves a letting by its unique identifier and displays + its details on the letting detail page. + + An informational log entry is generated whenever the page is accessed. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :param letting_id: Unique identifier of the letting. + :type letting_id: int + :returns: Rendered letting detail page. + :rtype: HttpResponse + """ + logger.info( + "Letting detail accessed", + extra={ + "letting_id": letting_id, + "path": request.path, + "method": request.method, + }, + ) + letting = get_object_or_404(Letting, id=letting_id) + context = { + "title": letting.title, + "address": letting.address, + } + return render(request, "lettings/letting.html", context) diff --git a/manage.py b/manage.py index c0e27e034a..14f26f0945 100755 --- a/manage.py +++ b/manage.py @@ -1,8 +1,29 @@ +""" +Management script for the Django project. + +This module serves as the command-line utility for administrative tasks +in the Django project, such as running the development server, +applying migrations, creating superusers, and executing custom management commands. + +It sets the default settings module for the Django project and +delegates execution to Django's built-in command-line utility. +""" + import os import sys def main(): + """ + Entrypoint for the Django management script. + + Ensures that the DJANGO_SETTINGS_MODULE environment variable is set + to the project's settings module and invokes Django's + execute_from_command_line function to handle command-line arguments. + + Raises: + ImportError: If Django is not installed or cannot be imported. + """ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') try: from django.core.management import execute_from_command_line diff --git a/oc-lettings-site.sqlite3 b/oc-lettings-site.sqlite3 index 3d885414f9..bff34c680f 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 deleted file mode 100644 index 63328c6dd3..0000000000 --- a/oc_lettings_site/admin.py +++ /dev/null @@ -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..456a5d5786 100644 --- a/oc_lettings_site/apps.py +++ b/oc_lettings_site/apps.py @@ -1,5 +1,23 @@ +""" +Application configuration for the oc_lettings_site project. + +This module defines the configuration class used by Django +to initialize and register the main project application. + +Although minimal, this configuration class serves as the +entry point for application-level customization, such as +signal registration or startup logic. +""" + from django.apps import AppConfig class OCLettingsSiteConfig(AppConfig): - name = 'oc_lettings_site' + """ + Configuration class for the oc_lettings_site application. + + This class declares the application name and provides a + foundation for future project-wide initialization behavior. + """ + + name = "oc_lettings_site" diff --git a/oc_lettings_site/asgi.py b/oc_lettings_site/asgi.py index 61f2d23ba3..2768640dfb 100644 --- a/oc_lettings_site/asgi.py +++ b/oc_lettings_site/asgi.py @@ -1,7 +1,20 @@ +""" +ASGI configuration for the oc_lettings_site project. + +This module exposes the ASGI callable as a module-level variable +named ``application``. + +It is used by ASGI-compatible web servers to serve the Django +application and enables support for asynchronous features +such as WebSockets or long-lived connections. +""" + import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') +# Set the default Django settings module for the ASGI application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oc_lettings_site.settings") +# ASGI application callable used by compatible servers application = get_asgi_application() diff --git a/oc_lettings_site/migrations/0001_initial.py b/oc_lettings_site/migrations/0001_initial.py index 774cf23f58..a076830454 100644 --- a/oc_lettings_site/migrations/0001_initial.py +++ b/oc_lettings_site/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -16,31 +15,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 deleted file mode 100644 index ed255e8c11..0000000000 --- a/oc_lettings_site/models.py +++ /dev/null @@ -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..d332c1290e 100644 --- a/oc_lettings_site/settings.py +++ b/oc_lettings_site/settings.py @@ -1,114 +1,164 @@ +""" +Application settings for the Orange County Lettings project. + +This module defines the Django configuration shared across development, +testing and production environments. + +The configuration relies on environment variables to adapt the application's +behavior, including database selection, static file management, logging and +error monitoring. +""" + import os +from dotenv import load_dotenv +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.logging import LoggingIntegration + +import logging from pathlib import Path +# Load environment variables +load_dotenv() + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # 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 = os.getenv("DEBUG", "False") == "True" + +ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".onrender.com"] + +SENTRY_DSN = os.getenv("SENTRY_DSN") + +if SENTRY_DSN: -ALLOWED_HOSTS = [] + sentry_logging = LoggingIntegration( + level=logging.INFO, + event_level=logging.ERROR, + ) + sentry_sdk.init( + dsn=SENTRY_DSN, integrations=[DjangoIntegration(), sentry_logging], traces_sample_rate=1.0 + ) # 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', + "oc_lettings_site.apps.OCLettingsSiteConfig", + "lettings", + "profiles", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] 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, "global_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'), - } -} +USE_SQLITE = os.getenv("USE_SQLITE", "").strip().lower() == "true" +if USE_SQLITE: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": str(BASE_DIR / "oc-lettings-site.sqlite3"), + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB"), + "USER": os.getenv("POSTGRES_USER"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + } + } -# Password validation +# Password validations # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 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", }, ] - # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "Etc/UTC" USE_I18N = True USE_L10N = True -USE_TZ = True - +USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +STATICFILES_DIRS = [ + BASE_DIR / "static", +] -STATIC_URL = '/static/' -STATICFILES_DIRS = [BASE_DIR / "static",] +STATICFILES_STORAGE = ( + "whitenoise.storage.CompressedStaticFilesStorage" +) diff --git a/oc_lettings_site/tests.py b/oc_lettings_site/tests.py index 3fd62bb718..1f57c6c8f3 100644 --- a/oc_lettings_site/tests.py +++ b/oc_lettings_site/tests.py @@ -1,2 +1,127 @@ -def test_dummy(): - assert 1 +""" +URL tests for the main Django project (oc_lettings_site). + +This module verifies that the root URL configuration correctly +routes requests to the expected views or included URL modules. + +The following aspects are tested: +- URL reversing for the home page +- URL resolution for the home page +- HTTP response for the home page +- Accessibility of included URL patterns (lettings, profiles, admin) +""" + +import pytest +from django.urls import reverse, resolve +from oc_lettings_site import views + +from django.test import RequestFactory +from django.core.handlers.exception import response_for_exception + + +@pytest.mark.django_db +def test_index_url_reverse(): + """ + Test that the home page URL is correctly reversed. + + Ensures that the named route 'index' + generates the expected root path. + """ + url = reverse("index") + assert url == "/" + + +@pytest.mark.django_db +def test_index_url_resolves(): + """ + Test that the home page URL resolves + to the correct view function. + """ + resolver = resolve("/") + assert resolver.func == views.index + + +@pytest.mark.django_db +def test_index_http_response(client): + """ + Integration test ensuring that the home page + returns an HTTP 200 response. + """ + response = client.get(reverse("index")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_lettings_url_accessible(client): + """ + Test that the lettings URL prefix is accessible + through the project-level URL configuration. + """ + response = client.get("/lettings/") + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_profiles_url_accessible(client): + """ + Test that the profiles URL prefix is accessible + through the project-level URL configuration. + """ + response = client.get("/profiles/") + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_admin_url_accessible(client): + """ + Test that the Django admin interface + is accessible through the project URL configuration. + + Note: + The admin page may redirect to the login page + if the user is not authenticated. + """ + response = client.get("/admin/") + assert response.status_code in (200, 302) + + +""" +View tests for the main Django project (oc_lettings_site). + +This module verifies that the project-level views return +correct HTTP responses and render the expected templates. + +The following view is tested: +- index +""" + + +@pytest.mark.django_db +def test_index_view_status_code(client): + """ + Test that the index view returns HTTP 200. + """ + response = client.get(reverse("index")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_index_view_template(client): + """ + Test that the index view renders the correct template. + """ + response = client.get(reverse("index")) + assert "index.html" in [t.name for t in response.templates] + + +def test_500_handler_unit(): + factory = RequestFactory() + request = factory.get("/") + + try: + raise Exception("forced") + except Exception as e: + response = response_for_exception(request, e) + + assert response.status_code == 500 + assert "Something went wrong on our side" in response.content.decode() diff --git a/oc_lettings_site/urls.py b/oc_lettings_site/urls.py index f0ff5897ab..ae9daea95b 100644 --- a/oc_lettings_site/urls.py +++ b/oc_lettings_site/urls.py @@ -1,13 +1,31 @@ +""" +Root URL configuration for the Orange County Lettings project. + +This module defines the application's main URL routing, including the +home page, the Django administration interface and the URL configurations +of the ``lettings`` and ``profiles`` applications. + +Custom handlers for HTTP 404 and 500 errors are also registered here. +""" + from django.contrib import admin -from django.urls import path +from django.urls import path, include from . import views 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), + # Home page + path("", views.index, name="index"), + # Lettings app: routes are defined in lettings.urls + path("lettings/", include("lettings.urls")), + # Profiles app: routes are defined in profiles.urls + path("profiles/", include("profiles.urls")), + # Django admin interface + path("admin/", admin.site.urls), ] + + +# Error handling: + +handler404 = "oc_lettings_site.views.page_not_found" +handler500 = "oc_lettings_site.views.server_error" diff --git a/oc_lettings_site/views.py b/oc_lettings_site/views.py index a72db27074..4f269be6d7 100644 --- a/oc_lettings_site/views.py +++ b/oc_lettings_site/views.py @@ -1,45 +1,67 @@ -from django.shortcuts import render -from .models import Letting, Profile +""" +Views for the main Django project (oc_lettings_site). + +This module defines the views responsible for rendering the application's +home page and custom HTTP error pages. +""" +from django.shortcuts import render +import logging +logger = logging.getLogger(__name__) -# 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) - -# 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) + """ + Render the application's home page. + + This view handles requests to the root URL (``/``) and returns + the main landing page of the Orange County Lettings application. + + An informational log entry is generated each time the page is + successfully accessed. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :returns: Rendered home page. + :rtype: HttpResponse + """ + logger.info("Homepage accessed") + + return render(request, "index.html") + + +def page_not_found(request, exception): + """ + Render the custom 404 error page. + + This view is invoked when Django cannot resolve the requested URL. + + A warning is logged to record the requested path and HTTP method. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :param exception: URL resolution exception. + :type exception: Exception + :returns: Rendered 404 error page. + :rtype: HttpResponse + """ + logger.warning("404 error encountered", extra={"path": request.path, "method": request.method}) + return render(request, "404.html", status=404) + + +def server_error(request): + """ + Render the custom 500 error page. + + This view is called whenever an unhandled server error occurs. + + An error is logged to facilitate monitoring and post-mortem debugging. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :returns: Rendered 500 error page. + :rtype: HttpResponse + """ + logger.error("500 error encountered", extra={"path": request.path, "method": request.method}) + return render(request, "500.html", status=500) diff --git a/oc_lettings_site/wsgi.py b/oc_lettings_site/wsgi.py index d78ca6d669..4aed681638 100644 --- a/oc_lettings_site/wsgi.py +++ b/oc_lettings_site/wsgi.py @@ -1,7 +1,19 @@ -import os +""" +WSGI config for oc_lettings_site project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It exposes the WSGI callable as a module-level +variable named `application`. +WSGI (Web Server Gateway Interface) is a specification that describes how a web server +communicates with web applications. +""" + +import os from django.core.wsgi import get_wsgi_application +# Set the default settings module for the 'oc_lettings_site' project. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oc_lettings_site.settings') +# WSGI application callable for serving the project. 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..321e28ba87 --- /dev/null +++ b/profiles/admin.py @@ -0,0 +1,22 @@ +""" +Admin configuration for the profiles application. + +This module registers the Profile model with the Django admin interface, +allowing administrators to manage user profile data through +the built-in Django admin site. +""" + +from django.contrib import admin +from .models import Profile + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + """ + Admin configuration for the Profile model. + + This class enables the management of Profile instances + within the Django admin interface. + """ + + pass diff --git a/profiles/apps.py b/profiles/apps.py new file mode 100644 index 0000000000..5f62818252 --- /dev/null +++ b/profiles/apps.py @@ -0,0 +1,19 @@ +""" +App configuration for the profiles application. + +This module defines the ProfilesConfig class, which provides +Django with metadata and configuration for the profiles app. +""" + +from django.apps import AppConfig + + +class ProfilesConfig(AppConfig): + """ + Configuration class for the profiles app. + + The `name` attribute specifies the full Python path to + the application, enabling Django to correctly register + and load the app within the project. + """ + name = 'profiles' diff --git a/profiles/conftest.py b/profiles/conftest.py new file mode 100644 index 0000000000..6d2fc8b8a7 --- /dev/null +++ b/profiles/conftest.py @@ -0,0 +1,36 @@ +""" +Models for the profiles application. + +This module defines the Profile model, which extends the built-in +Django User model with additional application-specific data, +such as the user's favorite city. +""" + +import pytest +from django.contrib.auth.models import User +from profiles.models import Profile + + +@pytest.fixture +def user(): + """ + Fixture that creates a sample Django User instance. + + Returns: + User: A saved User instance with test credentials. + """ + return User.objects.create(username="testuser") + + +@pytest.fixture +def profile(user): + """ + Fixture that creates a sample Profile instance linked to a User. + + Args: + user (User): Fixture providing a User instance. + + Returns: + Profile: A saved Profile instance with sample data. + """ + return Profile.objects.create(user=user, favorite_city="Paris") diff --git a/profiles/migrations/0001_initial.py b/profiles/migrations/0001_initial.py new file mode 100644 index 0000000000..e44c28e987 --- /dev/null +++ b/profiles/migrations/0001_initial.py @@ -0,0 +1,12 @@ +# Generated by Django 3.0 on 2026-02-01 10:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + ] diff --git a/profiles/migrations/0002_auto_20260205_1014.py b/profiles/migrations/0002_auto_20260205_1014.py new file mode 100644 index 0000000000..2796c38fde --- /dev/null +++ b/profiles/migrations/0002_auto_20260205_1014.py @@ -0,0 +1,47 @@ +from django.db import migrations, models +from django.conf import settings +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0001_initial"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="new_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "favorite_city", + models.CharField(max_length=64, blank=True), + ), + ], + options={ + "db_table": "oc_lettings_site_profile", + }, + ), + ], + ), + ] diff --git a/profiles/migrations/0003_auto_20260205_1036.py b/profiles/migrations/0003_auto_20260205_1036.py new file mode 100644 index 0000000000..b24173737a --- /dev/null +++ b/profiles/migrations/0003_auto_20260205_1036.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0 on 2026-02-05 09:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0002_auto_20260205_1014'), + ] + + operations = [ + migrations.AlterModelTable( + name='Profile', + table='profiles_profile', + ), + ] 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..653d1185f7 --- /dev/null +++ b/profiles/models.py @@ -0,0 +1,42 @@ +""" +Database models for the ``profiles`` application. + +This module defines the data model used to associate additional +profile information with Django's built-in authentication system. +""" + +from django.db import models +from django.contrib.auth.models import User + + +class Profile(models.Model): + """ + Store additional information associated with a Django user. + + Each profile is linked to a single ``User`` instance through a + one-to-one relationship and currently stores the user's favorite city. + + The model extends Django's authentication system without modifying + the built-in ``User`` model. + """ + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="new_user") + favorite_city = models.CharField(max_length=64, blank=True) + + def __str__(self): + """ + Return the username associated with this profile. + + :returns: Username of the related user. + :rtype: str + """ + return self.user.username + + class Meta: + """ + Define metadata associated with the ``Profile`` model. + + The explicit database table name preserves compatibility with the + original project schema. + """ + db_table = "profiles_profile" 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..76baf754b0 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 94% rename from templates/profile.html rename to profiles/templates/profiles/profile.html index d150d30e63..a5558e0da0 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..98ae2c75bd --- /dev/null +++ b/profiles/tests.py @@ -0,0 +1,402 @@ +""" +Unit tests for the Profile model in profiles.models. + +This module verifies: +- The string representation of Profile +- Correct field values +- Relationship with Django User +""" + +import pytest +from django.urls import reverse, resolve +from django.contrib.auth.models import User +from profiles import views + + +@pytest.mark.django_db +def test_profile_str(profile): + """ + Test the string representation of the Profile model. + + The __str__ method should return the username + of the associated user. + """ + assert str(profile) == "testuser" + + +@pytest.mark.django_db +def test_profile_fields(profile, user): + """ + Test that Profile fields are correctly stored. + + Args: + profile (Profile): Fixture providing a Profile instance. + user (User): Fixture providing a User instance. + """ + assert profile.user == user + assert profile.favorite_city == "Paris" + + +@pytest.mark.django_db +def test_profile_user_relationship(profile): + """ + Test the one-to-one relationship between User and Profile. + + Ensures the related_name 'new_user' works correctly. + """ + assert profile.user.new_user == profile + + +""" +URL tests for the profiles application. + +This module verifies that URL patterns defined in profiles.urls +correctly resolve to the expected view functions and that +URL reversing works as intended. +""" + + +@pytest.mark.django_db +def test_profiles_index_url_reverse(): + """ + Test that the profiles index URL is correctly reversed. + + Ensures the 'profiles:profiles_index' named route + generates the expected URL path. + """ + url = reverse("profiles:profiles_index") + assert url == "/profiles/" + + +@pytest.mark.django_db +def test_profiles_index_url_resolves(): + """ + Test that the profiles index URL resolves + to the correct view function. + """ + resolver = resolve("/profiles/") + assert resolver.func == views.profiles_index + + +@pytest.mark.django_db +def test_profile_detail_url_reverse(): + """ + Test that the profile detail URL is correctly reversed. + + Ensures the 'profiles:profile' named route generates + the expected URL when provided with a username. + """ + url = reverse("profiles:profile", args=["testuser"]) + assert url == "/profiles/testuser/" + + +@pytest.mark.django_db +def test_profile_detail_url_resolves(): + """ + Test that the profile detail URL resolves + to the correct view function. + """ + resolver = resolve("/profiles/testuser/") + assert resolver.func == views.profile + + +""" +View tests for the profiles application. + +This module verifies that profile-related views return the correct +HTTP responses, use the expected templates, and provide the correct +context data. +""" + + +@pytest.mark.django_db +def test_profiles_index_view_status_code(client, profile): + """ + Test that the profiles index view returns HTTP 200. + + Args: + client (Client): Django test client. + profile (Profile): Fixture providing a Profile instance. + """ + response = client.get(reverse("profiles:profiles_index")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_profiles_index_view_template(client, profile): + """ + Test that the profiles index view uses the correct template. + """ + response = client.get(reverse("profiles:profiles_index")) + assert "profiles/index.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_profiles_index_context(client, profile): + """ + Test that the profiles index view includes profiles in context. + """ + response = client.get(reverse("profiles:profiles_index")) + assert "profiles_list" in response.context + assert profile in response.context["profiles_list"] + + +@pytest.mark.django_db +def test_profile_detail_view_status_code(client, profile): + """ + Test that the profile detail view returns HTTP 200 + for an existing profile. + """ + response = client.get(reverse("profiles:profile", args=[profile.user.username])) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_profile_detail_view_template(client, profile): + """ + Test that the profile detail view uses the correct template. + """ + response = client.get(reverse("profiles:profile", args=[profile.user.username])) + assert "profiles/profile.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_profile_detail_context(client, profile): + """ + Test that the profile detail view passes the correct + profile object to the template context. + """ + response = client.get(reverse("profiles:profile", args=[profile.user.username])) + assert "profile" in response.context + assert response.context["profile"] == profile + + +@pytest.mark.django_db +def test_profile_detail_nonexistent_returns_404(client): + """ + Ensure that requesting a non-existing profile returns HTTP 404. + + The view uses get_object_or_404, so a missing profile should + result in a 404 response instead of an exception. + """ + response = client.get(reverse("profiles:profile", args=["unknownuser"])) + + assert response.status_code == 404 + assert "Sorry, the page you are looking for does not exist." in response.content.decode() + + +""" +Integration tests for the `profiles` Django application. + +These tests validate the correct behavior of the profiles application +by exercising the full Django request-response lifecycle: + + URL routing → View execution → Database queries → Template rendering → HTTP response + +Unlike unit tests that focus on isolated components such as models +or individual view functions, these integration tests ensure that +multiple layers of the application interact correctly when accessed +through HTTP requests. + +The tests cover several categories of behavior: + +Happy paths: + - Accessing the profiles index page successfully + - Rendering existing profiles in the index page + - Navigating to a specific profile page + - Displaying profile details retrieved from the database + +Edge cases: + - Handling situations where no profiles exist + +Sad paths (Test-Driven Development approach): + - Requesting a profile that does not exist should return HTTP 404 + - Requesting a user without an associated profile should return HTTP 404 + +Some tests intentionally document expected behaviors that may not yet +be implemented in the application. These tests may initially fail +until proper error handling is introduced (for example using +Django's `get_object_or_404` helper). + +This follows a Test-Driven Development (TDD) approach where expected +application behavior is specified before implementing the logic. +""" + + +@pytest.mark.django_db +def test_profiles_index_page_accessible(client): + """ + Verify that the profiles index page is accessible. + + This test ensures that the URL associated with the profiles + index view resolves correctly and returns a valid HTTP response. + + The test validates that: + - the URL can be generated using Django's reverse function + - the view returns HTTP status code 200 + - the request completes without errors + + This confirms that URL routing, view execution, and template + rendering are correctly integrated. + """ + url = reverse("profiles:profiles_index") + + response = client.get(url) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_profiles_index_uses_correct_template(client): + """ + Verify that the profiles index view renders the correct template. + + The view should render the 'profiles/index.html' template. + This test ensures that the template layer is correctly + connected to the view logic. + """ + url = reverse("profiles:profiles_index") + + response = client.get(url) + + templates = [t.name for t in response.templates] + + assert "profiles/index.html" in templates + + +@pytest.mark.django_db +def test_profiles_index_displays_existing_profiles(client, profile): + """ + Verify that existing profiles are displayed on the index page. + + This test creates a Profile instance using a fixture and ensures + that the username associated with the profile appears in the + rendered HTML response. + + This confirms that: + - profiles are correctly retrieved from the database + - the context variable `profiles_list` is passed to the template + - the template correctly renders profile usernames. + """ + url = reverse("profiles:profiles_index") + + response = client.get(url) + + content = response.content.decode() + + assert profile.user.username in content + + +@pytest.mark.django_db +def test_profiles_index_handles_empty_dataset(client): + """ + Verify the behavior of the index page when no profiles exist. + + If the database contains no Profile instances, the template + should display a fallback message informing the user that + no profiles are available. + + This ensures the application handles empty datasets gracefully. + """ + url = reverse("profiles:profiles_index") + + response = client.get(url) + + content = response.content.decode() + + assert response.status_code == 200 + assert "No profiles are available." in content + + +@pytest.mark.django_db +def test_profile_detail_page_accessible(client, profile): + """ + Verify that the profile detail page is accessible for an existing user. + + This test ensures that the dynamic URL using the username parameter + resolves correctly and retrieves the associated profile. + + The test validates that: + - the URL is correctly generated with reverse() + - the view returns HTTP status code 200 + - the profile template is used for rendering the response. + """ + url = reverse("profiles:profile", args=[profile.user.username]) + + response = client.get(url) + + assert response.status_code == 200 + assert "profiles/profile.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_profile_detail_displays_user_information(client, profile): + """ + Verify that the profile detail page displays user information. + + The template should render fields associated with the User model + as well as the Profile model itself. + + This test ensures that the following data appears in the response: + - username + - favorite city + + This confirms the correct integration between the Profile model, + the related User model, the view logic, and the template rendering. + """ + url = reverse("profiles:profile", args=[profile.user.username]) + + response = client.get(url) + + content = response.content.decode() + + assert profile.user.username in content + assert profile.favorite_city in content + + +@pytest.mark.django_db +def test_profile_detail_returns_404_for_unknown_username(client): + """ + Verify that requesting a profile with an unknown username + returns HTTP 404. + + According to standard web application behavior, requesting + a resource that does not exist should return a 404 response. + + This test documents the expected behavior of the application + when the requested profile does not exist in the database. + + The current implementation may raise an unhandled exception + which results in a 500 error. This test follows a + Test-Driven Development approach and will pass once proper + error handling is implemented. + """ + url = reverse("profiles:profile", args=["unknown_user"]) + + response = client.get(url) + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_profile_detail_user_without_profile_returns_404(client): + """ + Verify that requesting a user without an associated profile + returns HTTP 404. + + In some cases a Django User may exist without a corresponding + Profile instance. The application should handle this situation + gracefully and return a 404 response rather than raising an + unhandled exception. + + This test documents the expected behavior and may initially + fail until proper error handling is implemented in the view. + """ + user = User.objects.create(username="orphan_user") + + url = reverse("profiles:profile", args=[user.username]) + + response = client.get(url) + + assert response.status_code == 404 + assert "Sorry, the page you are looking for does not exist." in response.content.decode() diff --git a/profiles/urls.py b/profiles/urls.py new file mode 100644 index 0000000000..0cf4efc226 --- /dev/null +++ b/profiles/urls.py @@ -0,0 +1,20 @@ +""" +URL configuration for the ``profiles`` application. + +This module maps the application's URL patterns to the corresponding +view functions and defines the namespace used for URL resolution. +""" + +from django.urls import path +from . import views + +app_name = "profiles" + +urlpatterns = [ + # Root path: displays the list of all available profiles + path("", views.profiles_index, name="profiles_index"), + + # Dynamic route: displays details for a specific profile + # identified by the username + path("/", views.profile, name="profile"), +] diff --git a/profiles/views.py b/profiles/views.py new file mode 100644 index 0000000000..302d027daf --- /dev/null +++ b/profiles/views.py @@ -0,0 +1,71 @@ +""" +Views for the ``profiles`` application. + +This module provides the views responsible for displaying the list +of user profiles and individual profile details. +""" + +from django.shortcuts import render, get_object_or_404 +from .models import Profile + +import logging + +logger = logging.getLogger(__name__) + + +def profiles_index(request): + """ + Render the page listing all user profiles. + + This view handles requests to the profiles index page and displays + every available profile. + + An informational log entry is generated whenever the page is accessed. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :returns: Rendered profiles index page. + :rtype: HttpResponse + """ + + logger.info( + "Profiles list accessed", + extra={ + "path": request.path, + "method": request.method, + }, + ) + profiles_list = Profile.objects.all() + context = {"profiles_list": profiles_list} + return render(request, "profiles/index.html", context) + + +def profile(request, username): + """ + Render the detail page of a user profile. + + This view retrieves the profile associated with the provided username + and displays its information. + + An informational log entry is generated whenever a profile is viewed. + + :param request: Incoming HTTP request. + :type request: HttpRequest + :param username: Username identifying the requested profile. + :type username: str + :returns: Rendered profile detail page. + :rtype: HttpResponse + :raises Http404: If no matching profile exists. + """ + + logger.info( + "Profile detail accessed", + extra={ + "username": username, + "path": request.path, + "method": request.method, + }, + ) + profile = get_object_or_404(Profile, user__username=username) + context = {"profile": profile} + return render(request, "profiles/profile.html", context) diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 0000000000..57ff28d690 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.10" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - requirements: requirements.txt \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c48c84ea40..8a0119c437 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000..364ef5333d --- /dev/null +++ b/start.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +echo "Applying migrations..." +python manage.py migrate --noinput + +echo "Starting Gunicorn..." +exec gunicorn oc_lettings_site.wsgi:application \ + --bind 0.0.0.0:${PORT:-8000} \ No newline at end of file