From 2a145ce4e6063f4b9b2a1f0174b69bf756d102d3 Mon Sep 17 00:00:00 2001 From: la-machine Date: Wed, 10 Dec 2025 17:32:04 +0100 Subject: [PATCH 1/3] Swagger updated --- .env.example | 28 +++ .flake8 | 5 + .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 24 ++ .github/ISSUE_TEMPLATE/dev_issues.md | 28 +++ .github/ISSUE_TEMPLATE/feature_request.md | 10 + .../pull_request_template.md | 16 ++ .github/actions/code_linting.yml | 69 ++++++ .github/actions/emojifying_pr.yml | 15 ++ .gitignore | 35 +++ .isort.cfg | 4 + CODE _OF_CONDUCT.md | 45 ++++ LICENSE | 21 ++ README.md | 50 ++++ apps/__init__.py | 0 apps/blog/__init__.py | 0 apps/blog/admin.py | 12 + apps/blog/apps.py | 16 ++ apps/blog/migrations/0001_initial.py | 83 +++++++ apps/blog/migrations/0002_initial.py | 88 +++++++ apps/blog/migrations/__init__.py | 0 apps/blog/models/__init__.py | 0 apps/blog/models/author.py | 13 ++ apps/blog/models/blog.py | 20 ++ apps/blog/models/category.py | 10 + apps/blog/models/image.py | 12 + apps/blog/models/tag.py | 9 + apps/blog/permissions.py | 8 + apps/blog/routes/__init__.py | 0 apps/blog/routes/api.py | 21 ++ apps/blog/serializers/__init__.py | 0 apps/blog/serializers/author_serializer.py | 8 + apps/blog/serializers/blog_serializer.py | 99 ++++++++ apps/blog/serializers/category_serializer.py | 7 + apps/blog/serializers/image_serializer.py | 7 + apps/blog/serializers/tag_serializer.py | 7 + apps/blog/signals.py | 10 + apps/blog/tasks.py | 7 + apps/blog/test/__init__.py | 0 apps/blog/test/test_tag_model.py | 16 ++ apps/blog/views/__init__.py | 0 apps/blog/views/author.py | 7 + apps/blog/views/blog.py | 17 ++ apps/blog/views/category.py | 7 + apps/blog/views/image.py | 17 ++ apps/blog/views/index.py | 5 + apps/blog/views/post.py | 64 +++++ apps/blog/views/tag.py | 7 + apps/events/__init__.py | 0 apps/events/admin.py | 19 ++ apps/events/apps.py | 7 + apps/events/migrations/0001_initial.py | 149 ++++++++++++ apps/events/migrations/0002_initial.py | 120 ++++++++++ apps/events/migrations/__init__.py | 0 apps/events/models/__init__.py | 3 + apps/events/models/constants.py | 35 +++ apps/events/models/event.py | 107 +++++++++ apps/events/models/partners.py | 26 +++ apps/events/models/projects.py | 28 +++ apps/events/models/reservation.py | 25 ++ apps/events/models/speaker.py | 106 +++++++++ apps/events/paginators.py | 0 apps/events/routes/__init__.py | 1 + apps/events/routes/api.py | 12 + apps/events/routes/extra.py | 7 + apps/events/serializers/__init__.py | 0 apps/events/serializers/event_serializer.py | 85 +++++++ .../serializers/reservation_serializer.py | 47 ++++ apps/events/serializers/speaker_serializer.py | 18 ++ apps/events/serializers/upload_serializer.py | 5 + apps/events/signals/__init__.py | 0 apps/events/signals/main.py | 10 + apps/events/test/__init__.py | 1 + apps/events/views/__init__.py | 0 apps/events/views/event.py | 188 +++++++++++++++ apps/events/views/reservation.py | 161 +++++++++++++ apps/events/views/speaker.py | 128 ++++++++++ apps/events/views/uploader.py | 41 ++++ apps/users/__init__.py | 0 apps/users/admin.py | 6 + apps/users/apps.py | 7 + apps/users/helpers/__init__.py | 0 apps/users/helpers/auth.py | 29 +++ apps/users/migrations/0001_initial.py | 87 +++++++ apps/users/migrations/__init__.py | 0 apps/users/models/__init__.py | 3 + apps/users/models/base_model.py | 56 +++++ apps/users/models/otp_code.py | 22 ++ apps/users/models/user.py | 71 ++++++ apps/users/models/user_manager.py | 26 +++ apps/users/pagination.py | 26 +++ apps/users/permissions/__init__.py | 0 apps/users/permissions/user_permissions.py | 84 +++++++ apps/users/routes/__init__.py | 1 + apps/users/routes/api.py | 14 ++ apps/users/routes/main.py | 0 apps/users/serializers/__init__.py | 2 + apps/users/serializers/auth_serializers.py | 89 +++++++ apps/users/serializers/general_serializers.py | 69 ++++++ apps/users/signals/__init__.py | 0 apps/users/signals/base.py | 25 ++ apps/users/test/__init__.py | 1 + apps/users/views/__init__.py | 0 apps/users/views/auth_view.py | 219 ++++++++++++++++++ apps/users/views/general_viewsets.py | 37 +++ apps/users/views/index.py | 25 ++ apps/users/views/user_views.py | 75 ++++++ config/docker/Dockerfile | 27 +++ config/docker/requirements.txt | 25 ++ config/docker/supervisord-celery.conf | 11 + config/docker/supervisord-web.conf | 11 + config/script/start.sh | 7 + documentation/main.md | 1 + exceptions/__init__.py | 0 exceptions/rest_exception.py | 159 +++++++++++++ manage.py | 22 ++ middlewares/__init__.py | 0 middlewares/translator.py | 25 ++ mixins/__init__.py | 1 + mixins/api_response_mixin.py | 92 ++++++++ requirements.txt | 38 +++ services/__init__.py | 1 + services/mail_service.py | 45 ++++ templates/docs/redoc.html | 26 +++ templates/mails/otp.html | 13 ++ utils/__init__.py | 1 + utils/auth.py | 33 +++ utils/main.py | 50 ++++ utils/uploads/__init__.py | 0 utils/uploads/main.py | 0 website_api/__init__.py | 0 website_api/asgi.py | 16 ++ website_api/celery.py | 18 ++ website_api/routes/__init__.py | 1 + website_api/routes/main.py | 25 ++ website_api/routes/swagger.py | 26 +++ website_api/settings/__init__.py | 3 + website_api/settings/apps.py | 50 ++++ website_api/settings/base.py | 149 ++++++++++++ website_api/settings/blog.py | 0 website_api/settings/celery.py | 11 + website_api/settings/extra.py | 119 ++++++++++ website_api/wsgi.py | 16 ++ 143 files changed, 4253 insertions(+) create mode 100644 .env.example create mode 100644 .flake8 create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/dev_issues.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/pull_request_template.md create mode 100644 .github/actions/code_linting.yml create mode 100644 .github/actions/emojifying_pr.yml create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 CODE _OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/__init__.py create mode 100644 apps/blog/__init__.py create mode 100644 apps/blog/admin.py create mode 100644 apps/blog/apps.py create mode 100644 apps/blog/migrations/0001_initial.py create mode 100644 apps/blog/migrations/0002_initial.py create mode 100644 apps/blog/migrations/__init__.py create mode 100644 apps/blog/models/__init__.py create mode 100644 apps/blog/models/author.py create mode 100644 apps/blog/models/blog.py create mode 100644 apps/blog/models/category.py create mode 100644 apps/blog/models/image.py create mode 100644 apps/blog/models/tag.py create mode 100644 apps/blog/permissions.py create mode 100644 apps/blog/routes/__init__.py create mode 100644 apps/blog/routes/api.py create mode 100644 apps/blog/serializers/__init__.py create mode 100644 apps/blog/serializers/author_serializer.py create mode 100644 apps/blog/serializers/blog_serializer.py create mode 100644 apps/blog/serializers/category_serializer.py create mode 100644 apps/blog/serializers/image_serializer.py create mode 100644 apps/blog/serializers/tag_serializer.py create mode 100644 apps/blog/signals.py create mode 100644 apps/blog/tasks.py create mode 100644 apps/blog/test/__init__.py create mode 100644 apps/blog/test/test_tag_model.py create mode 100644 apps/blog/views/__init__.py create mode 100644 apps/blog/views/author.py create mode 100644 apps/blog/views/blog.py create mode 100644 apps/blog/views/category.py create mode 100644 apps/blog/views/image.py create mode 100644 apps/blog/views/index.py create mode 100644 apps/blog/views/post.py create mode 100644 apps/blog/views/tag.py create mode 100644 apps/events/__init__.py create mode 100644 apps/events/admin.py create mode 100644 apps/events/apps.py create mode 100644 apps/events/migrations/0001_initial.py create mode 100644 apps/events/migrations/0002_initial.py create mode 100644 apps/events/migrations/__init__.py create mode 100644 apps/events/models/__init__.py create mode 100644 apps/events/models/constants.py create mode 100644 apps/events/models/event.py create mode 100644 apps/events/models/partners.py create mode 100644 apps/events/models/projects.py create mode 100644 apps/events/models/reservation.py create mode 100644 apps/events/models/speaker.py create mode 100644 apps/events/paginators.py create mode 100644 apps/events/routes/__init__.py create mode 100644 apps/events/routes/api.py create mode 100644 apps/events/routes/extra.py create mode 100644 apps/events/serializers/__init__.py create mode 100644 apps/events/serializers/event_serializer.py create mode 100644 apps/events/serializers/reservation_serializer.py create mode 100644 apps/events/serializers/speaker_serializer.py create mode 100644 apps/events/serializers/upload_serializer.py create mode 100644 apps/events/signals/__init__.py create mode 100644 apps/events/signals/main.py create mode 100644 apps/events/test/__init__.py create mode 100644 apps/events/views/__init__.py create mode 100644 apps/events/views/event.py create mode 100644 apps/events/views/reservation.py create mode 100644 apps/events/views/speaker.py create mode 100644 apps/events/views/uploader.py create mode 100644 apps/users/__init__.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/apps.py create mode 100644 apps/users/helpers/__init__.py create mode 100644 apps/users/helpers/auth.py create mode 100644 apps/users/migrations/0001_initial.py create mode 100644 apps/users/migrations/__init__.py create mode 100644 apps/users/models/__init__.py create mode 100644 apps/users/models/base_model.py create mode 100644 apps/users/models/otp_code.py create mode 100644 apps/users/models/user.py create mode 100644 apps/users/models/user_manager.py create mode 100644 apps/users/pagination.py create mode 100644 apps/users/permissions/__init__.py create mode 100644 apps/users/permissions/user_permissions.py create mode 100644 apps/users/routes/__init__.py create mode 100644 apps/users/routes/api.py create mode 100644 apps/users/routes/main.py create mode 100644 apps/users/serializers/__init__.py create mode 100644 apps/users/serializers/auth_serializers.py create mode 100644 apps/users/serializers/general_serializers.py create mode 100644 apps/users/signals/__init__.py create mode 100644 apps/users/signals/base.py create mode 100644 apps/users/test/__init__.py create mode 100644 apps/users/views/__init__.py create mode 100644 apps/users/views/auth_view.py create mode 100644 apps/users/views/general_viewsets.py create mode 100644 apps/users/views/index.py create mode 100644 apps/users/views/user_views.py create mode 100644 config/docker/Dockerfile create mode 100644 config/docker/requirements.txt create mode 100644 config/docker/supervisord-celery.conf create mode 100644 config/docker/supervisord-web.conf create mode 100644 config/script/start.sh create mode 100644 documentation/main.md create mode 100644 exceptions/__init__.py create mode 100644 exceptions/rest_exception.py create mode 100644 manage.py create mode 100644 middlewares/__init__.py create mode 100644 middlewares/translator.py create mode 100644 mixins/__init__.py create mode 100644 mixins/api_response_mixin.py create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/mail_service.py create mode 100644 templates/docs/redoc.html create mode 100644 templates/mails/otp.html create mode 100644 utils/__init__.py create mode 100644 utils/auth.py create mode 100644 utils/main.py create mode 100644 utils/uploads/__init__.py create mode 100644 utils/uploads/main.py create mode 100644 website_api/__init__.py create mode 100644 website_api/asgi.py create mode 100644 website_api/celery.py create mode 100644 website_api/routes/__init__.py create mode 100644 website_api/routes/main.py create mode 100644 website_api/routes/swagger.py create mode 100644 website_api/settings/__init__.py create mode 100644 website_api/settings/apps.py create mode 100644 website_api/settings/base.py create mode 100644 website_api/settings/blog.py create mode 100644 website_api/settings/celery.py create mode 100644 website_api/settings/extra.py create mode 100644 website_api/wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7bb8bd --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +ENVIRONMENT='development' +DEBUG=True +SECRET_KEY='' +ALLOWED_HOSTS='*' +CORS_ALLOWED_ORIGINS='http://127.0.0.1:3000' + +# Database +DB_HOST='localhost' +DB_NAME='django_cameroon' +DB_USER='' +DB_PASSWORD='' +DB_PORT='5432' + +# Email +EMAIL_HOST='smtp.gmail.com' +EMAIL_PORT=587 +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +EMAIL_USE_TLS=True +EMAIL_USE_SSL=False + +# Redis +REDIS_URL='redis://127.0.0.1:6379' + +# Twilio +TWILLIO_SID='' +TWILLIO_AUTH_TOKEN='' +TWILIO_VERIFY_SERVICE_SID='' diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f79579d --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 120 +ignore = F401, F403, F405, W503 +exclude = + */__init__.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f32b91c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@yokwejuste @Edmond22-prog @LL-Etiane @yll0rd diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1aeb2c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +about: Report a bug! +title: "" +labels: "bug :beetle:" +assignees: "" +--- + +## Describe the bug +A clear and concise description of what the bug is. + +## To Reproduce +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +## Screenshots + diff --git a/.github/ISSUE_TEMPLATE/dev_issues.md b/.github/ISSUE_TEMPLATE/dev_issues.md new file mode 100644 index 0000000..623b953 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dev_issues.md @@ -0,0 +1,28 @@ +--- +name: Developer issue +about: Issue with description, learning objectives, todo, and useful links +title: '' +labels: '' +assignees: '' +--- + +## Description + +## Learning objectives + +- Objective + +## Todo + +These points are a rough guideline. **Please** feel free to discuss with others on the team about the best way to design this component! + +- Todo + +## Useful links + +- Useful link + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9b52e9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,10 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "feature :star2:" +assignees: "" +--- +### Feature : +### Description : +### Acceptance criteria : diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..f9fc5bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +Closes #23 + +## Changes + +- Item 1 +- Item 2 +- Item 3 + +## Screenshots + +(Insert image, only for frontend) +Bearer d855f89533088ce049754df15432f411 \ No newline at end of file diff --git a/.github/actions/code_linting.yml b/.github/actions/code_linting.yml new file mode 100644 index 0000000..5a15c32 --- /dev/null +++ b/.github/actions/code_linting.yml @@ -0,0 +1,69 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + run-linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.8 + + - name: Install Python dependencies + run: pip install black flake8 + + - name: Run linters + uses: wearerequired/lint-action@v2.3.0 + with: + black: true + flake8: true + build: + name: Lint + runs-on: ubuntu-latest + + permissions: + contents: read + packages: read + statuses: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Super-linter + uses: super-linter/super-linter@v5.7.2 + env: + DEFAULT_BRANCH: main + GITHUB_TOKEN: ${{ secrets.GH_SECRET }} + lint: + runs-on: ubuntu-latest + container: python:3.10.11 + + steps: + - name: Install pipx + run: pip install pipx + - uses: actions/checkout@v3 + - name: flake8 + uses: py-actions/flake8@v2 + - name: ruff + uses: chartboost/ruff-action@v1 + - name: blake + uses: psf/black@stable + with: + options: "--check --verbose --line-length 120" + version: "22.10.0" diff --git a/.github/actions/emojifying_pr.yml b/.github/actions/emojifying_pr.yml new file mode 100644 index 0000000..00fff26 --- /dev/null +++ b/.github/actions/emojifying_pr.yml @@ -0,0 +1,15 @@ +name: Emojify PR Title + +on: pull_request + +jobs: + emojify-pr-title: + runs-on: ubuntu-latest + name: Emojify PR Title + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Emojify PR Title + uses: pineapplelol/emojify-pr-title@v1.5.4 + with: + use-emoji-map: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d2b97e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Ignore compiled Python files +*.pyc + +# Ignore the virtual environment +venv/ +.venv + +# Ignore the Django secret key +secret_key.txt + +# Ignore database files +db.sqlite3 + +# Ignore static files +staticfiles/ +static/ + +# Ignore media files +media/ + + +# Ignore log files +*.log + +# Ignore environment-specific settings +.env + +# Ignore IDE files +.vscode/ +.idea/ +*.pydevproject +.history/ + +# Ignore local development settings +local_settings.py diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..3f1bdcf --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,4 @@ +[settings] +profile = black +skip = .gitignore +line_length = 120 diff --git a/CODE _OF_CONDUCT.md b/CODE _OF_CONDUCT.md new file mode 100644 index 0000000..6aafc5d --- /dev/null +++ b/CODE _OF_CONDUCT.md @@ -0,0 +1,45 @@ +## Purpose +This project thrives thanks to its amazing contributors and users like you! To foster a collaborative and inclusive environment, +we've established a Code of Conduct outlining expected behavior for everyone involved. + +- We appreciate constructive criticism and open communication. +- We are committed to providing an environment free from discrimination and harassment. +- We expect participants to behave professionally and with courtesy towards each other. + +## Principles + +#### Respect +Treat everyone with dignity and understanding, regardless of background, identity, or opinion. +#### Professionalism +Stay professional, avoiding inflammatory language, personal attacks, and discrimination. +#### Constructive Criticism +Focus on the issue at hand, offering valuable feedback with kindness. +#### Open-mindedness +Embrace diverse perspectives and ideas, fostering healthy discussion. +#### Accountability +Own your actions and hold yourself responsible for maintaining a positive environment. + +## Unacceptable Behavior + +- Any form of harassment, intimidation, or discrimination. +- Offensive, abusive, or obscene language. +- Unsolicited sexual advances or unwanted attention. +- Public or private attacks. +- Sharing private information without consent. +- Spam or flooding community spaces. +- Any other conduct deemed disruptive or harmful. + +## Consequences + +Unacceptable behavior won't be tolerated. Depending on the severity, consequences may include warnings, +temporary or permanent bans, or reporting to relevant authorities. + +## Reporting Issues + +Witness or experience unacceptable behavior? Please report it discreetly to us through +[our email](djangocameroon@gmail.com). We take all reports seriously and investigate them promptly. + +## Enforcing the Code of Conduct: + +The maintainers of Django Cameroon website are responsible for enforcing this code of conduct. +We will review all reports and take appropriate action. We reserve the right to update this code of conduct at any time. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8259f2e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Django Cameroon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4687b3b --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# website_api + +## Installation +1. Create and activate a virtual environment +```bash +python3 -m venv venv +source venv/bin/activate +``` + +2. Install dependencies +```bash +pip3 install -r requirements.txt +``` + +3. Setup a new postgres database +Assuming postgresql is installed in your computer, follow what's next: + - ```bash + # accessing the postgres CLI + sudo -u postgres psql + ``` + - ```bash + + -- Create a database + CREATE DATABASE django_website_db; + + -- create a new user with the details below + CREATE USER 'db_username' WITH ENCRYPTED PASSWORD 'password'; + -- Grant all priviledges + GRANT ALL PRIVILEGES ON DATABASE django_website_db TO 'db_username'@'host'; + ``` + + If postgreSQL is not installed in the computer, get to the tutorial [for Linux](https://www.cherryservers.com/blog/how-to-install-and-setup-postgresql-server-on-ubuntu-20-04) or [for Windows](https://www.microfocus.com/documentation/idol/IDOL_12_0/MediaServer/Guides/html/English/Content/Getting_Started/Configure/_TRN_Set_up_PostgreSQL.htm). + +4. Copy the .env.example file to .env and fill in the values in the .env file. +```bash +cp -r .env.example .env +``` + +5. Apply database migrations +```bash +python3 manage.py migrate +``` + +6. Run the server +```bash +python3 manage.py runserver +``` + +## Contributing +Contributions are always welcome! If you have any bug reports, feature requests, or pull requests, please feel free to submit them. \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/admin.py b/apps/blog/admin.py new file mode 100644 index 0000000..6f2c072 --- /dev/null +++ b/apps/blog/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from apps.blog.models.tag import Tag +from apps.blog.models.category import Category +from apps.blog.models.author import Author +from apps.blog.models.blog import Blog +from apps.blog.models.image import Image + +admin.site.register(Tag) +admin.site.register(Category) +admin.site.register(Author) +admin.site.register(Blog) +admin.site.register(Image) diff --git a/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..ece6ff5 --- /dev/null +++ b/apps/blog/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig + + +# class BlogConfig(AppConfig): +# default_auto_field = 'django.db.models.BigAutoField' +# name = 'blog' + + + +class BlogConfig(AppConfig): + name = 'apps.blog' + default_auto_field = 'django.db.models.BigAutoField' + + def ready(self): + import apps.blog.signals + diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..1057e29 --- /dev/null +++ b/apps/blog/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.4 on 2025-07-06 16:00 + +import utils.main +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ('bio', models.TextField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Blog', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('title', models.CharField(max_length=200)), + ('content', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image_file', models.ImageField(upload_to='images/')), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/blog/migrations/0002_initial.py b/apps/blog/migrations/0002_initial.py new file mode 100644 index 0000000..f6c5815 --- /dev/null +++ b/apps/blog/migrations/0002_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.4 on 2025-07-06 16:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('blog', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='author', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='author', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='blog', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.author'), + ), + migrations.AddField( + model_name='blog', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='blog', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='category', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='category', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='blog', + name='categories', + field=models.ManyToManyField(to='blog.category'), + ), + migrations.AddField( + model_name='image', + name='blog_post', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='blog.blog'), + ), + migrations.AddField( + model_name='image', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='image', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='tag', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='tag', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='blog', + name='tags', + field=models.ManyToManyField(to='blog.tag'), + ), + ] diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models/__init__.py b/apps/blog/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models/author.py b/apps/blog/models/author.py new file mode 100644 index 0000000..8643288 --- /dev/null +++ b/apps/blog/models/author.py @@ -0,0 +1,13 @@ +from django.db import models +from apps.users.models.base_model import BaseModel + + + +class Author(BaseModel): + name = models.CharField(max_length=100) + bio = models.TextField() + + def __str__(self): + return self.name + + diff --git a/apps/blog/models/blog.py b/apps/blog/models/blog.py new file mode 100644 index 0000000..f2dd627 --- /dev/null +++ b/apps/blog/models/blog.py @@ -0,0 +1,20 @@ +from django.db import models +from apps.users.models.base_model import BaseModel +from apps.blog.models.author import Author +from apps.blog.models.tag import Tag +from apps.blog.models.category import Category + +class Blog(BaseModel): + title = models.CharField(max_length=200) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + author = models.ForeignKey(Author, on_delete=models.CASCADE) + categories = models.ManyToManyField(Category) + tags = models.ManyToManyField(Tag) + + def __str__(self): + return self.title + + + diff --git a/apps/blog/models/category.py b/apps/blog/models/category.py new file mode 100644 index 0000000..8f51858 --- /dev/null +++ b/apps/blog/models/category.py @@ -0,0 +1,10 @@ +from django.db import models +from apps.users.models.base_model import BaseModel + +class Category(BaseModel): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + diff --git a/apps/blog/models/image.py b/apps/blog/models/image.py new file mode 100644 index 0000000..c36131b --- /dev/null +++ b/apps/blog/models/image.py @@ -0,0 +1,12 @@ +from django.db import models +from apps.users.models.base_model import BaseModel +from apps.blog.models.blog import Blog + +class Image(BaseModel): + image_file = models.ImageField(upload_to='images/') + uploaded_at = models.DateTimeField(auto_now_add=True) + blog_post = models.ForeignKey(Blog, related_name='images', on_delete=models.CASCADE) + + def __str__(self): + return f"Image for {self.blog_post.title}" + diff --git a/apps/blog/models/tag.py b/apps/blog/models/tag.py new file mode 100644 index 0000000..a5f492f --- /dev/null +++ b/apps/blog/models/tag.py @@ -0,0 +1,9 @@ +from django.db import models +from apps.users.models.base_model import BaseModel + +class Tag(BaseModel): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + \ No newline at end of file diff --git a/apps/blog/permissions.py b/apps/blog/permissions.py new file mode 100644 index 0000000..124c52d --- /dev/null +++ b/apps/blog/permissions.py @@ -0,0 +1,8 @@ + +from rest_framework.permissions import BasePermission, SAFE_METHODS + +class IsAuthorOrReadOnly(BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.author == request.user \ No newline at end of file diff --git a/apps/blog/routes/__init__.py b/apps/blog/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/routes/api.py b/apps/blog/routes/api.py new file mode 100644 index 0000000..20c88d6 --- /dev/null +++ b/apps/blog/routes/api.py @@ -0,0 +1,21 @@ +from django.urls import path +from apps.blog.views.post import PostDetail, PostList +from apps.blog.views.blog import BlogListCreateView +from apps.blog.views.author import AuthorListView +from apps.blog.views.category import CategoryListView +from apps.blog.views.image import ImageListView, ImageCreateView +from apps.blog.views.tag import TagListView +from apps.blog.views.index import index + + +urlpatterns = [ + path('posts/', PostList.as_view(), name='post-list'), + path('posts//', PostDetail.as_view(), name='post-detail'), + path('posts/create/', BlogListCreateView.as_view(), name='post-create'), + path('authors/', AuthorListView.as_view(), name='author-list'), + path('categories/', CategoryListView.as_view(), name='category-list'), + path('images/', ImageListView.as_view(), name='image-list'), + path('images/create/', ImageCreateView.as_view(), name='image-create'), + path('tags/', TagListView.as_view(), name='tag-list'), + path('', index, name='index'), +] diff --git a/apps/blog/serializers/__init__.py b/apps/blog/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/serializers/author_serializer.py b/apps/blog/serializers/author_serializer.py new file mode 100644 index 0000000..63f83a9 --- /dev/null +++ b/apps/blog/serializers/author_serializer.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from apps.blog.models.author import Author + + +class AuthorSerializer(serializers.ModelSerializer): + class Meta: + model = Author + fields = ('id', 'name', 'bio') diff --git a/apps/blog/serializers/blog_serializer.py b/apps/blog/serializers/blog_serializer.py new file mode 100644 index 0000000..d6dfb99 --- /dev/null +++ b/apps/blog/serializers/blog_serializer.py @@ -0,0 +1,99 @@ +from rest_framework import serializers +from apps.blog.models.blog import Blog +from apps.blog.models.author import Author +from apps.blog.models.category import Category +from apps.blog.models.tag import Tag +from apps.blog.serializers.author_serializer import AuthorSerializer +from apps.blog.serializers.category_serializer import CategorySerializer +from apps.blog.serializers.tag_serializer import TagSerializer +from apps.blog.serializers.image_serializer import ImageSerializer + + +class BlogSerializer(serializers.ModelSerializer): + author = AuthorSerializer() + categories = CategorySerializer(many=True) + tags = TagSerializer(many=True) + images = ImageSerializer(many=True, read_only=True) + + class Meta: + model = Blog + fields = ['author', 'categories', 'tags', 'title', 'content', 'images'] + + def create(self, validated_data): + author_data = validated_data.pop('author') + categories_data = validated_data.pop('categories', []) + tags_data = validated_data.pop('tags', []) + + author_instance, _ = Author.objects.get_or_create(**author_data) + + blog = Blog.objects.create(author=author_instance, **validated_data) + + self._update_categories_and_tags(blog, categories_data, tags_data) + + return blog + + def update(self, instance, validated_data): + author_data = validated_data.pop('author', None) + categories_data = validated_data.pop('categories', []) + tags_data = validated_data.pop('tags', []) + + if author_data: + author_instance, _ = Author.objects.get_or_create(**author_data) + instance.author = author_instance + + instance.title = validated_data.get('title', instance.title) + instance.content = validated_data.get('content', instance.content) + instance.save() + + self._update_categories_and_tags(instance, categories_data, tags_data) + + return instance + + def _update_categories_and_tags(self, instance, categories_data, tags_data): + # Update categories + updated_categories = [] + for category_data in categories_data: + category, _ = Category.objects.get_or_create(**category_data) + updated_categories.append(category) + instance.categories.set(updated_categories) + + # Update tags + updated_tags = [] + for tag_data in tags_data: + tag, _ = Tag.objects.get_or_create(**tag_data) + updated_tags.append(tag) + instance.tags.set(updated_tags) + + +class BlogCreateUpdateSerializer(serializers.ModelSerializer): + author = serializers.PrimaryKeyRelatedField(queryset=Author.objects.all()) + categories = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True) + tags = serializers.PrimaryKeyRelatedField(queryset=Tag.objects.all(), many=True) + + class Meta: + model = Blog + fields = ['author', 'categories', 'tags', 'title', 'content'] + + def create(self, validated_data): + categories = validated_data.pop('categories') + tags = validated_data.pop('tags') + blog = Blog.objects.create(**validated_data) + blog.categories.set(categories) + blog.tags.set(tags) + return blog + + def update(self, instance, validated_data): + categories = validated_data.pop('categories', None) + tags = validated_data.pop('tags', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + if categories is not None: + instance.categories.set(categories) + + if tags is not None: + instance.tags.set(tags) + + instance.save() + return instance diff --git a/apps/blog/serializers/category_serializer.py b/apps/blog/serializers/category_serializer.py new file mode 100644 index 0000000..cea16f9 --- /dev/null +++ b/apps/blog/serializers/category_serializer.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from apps.blog.models.category import Category + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["name"] \ No newline at end of file diff --git a/apps/blog/serializers/image_serializer.py b/apps/blog/serializers/image_serializer.py new file mode 100644 index 0000000..484a88b --- /dev/null +++ b/apps/blog/serializers/image_serializer.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from apps.blog.models.image import Image + +class ImageSerializer(serializers.ModelSerializer): + class Meta: + model = Image + fields = ["image_file", "uploaded_at", "blog_post"] diff --git a/apps/blog/serializers/tag_serializer.py b/apps/blog/serializers/tag_serializer.py new file mode 100644 index 0000000..3111c07 --- /dev/null +++ b/apps/blog/serializers/tag_serializer.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from apps.blog.models.tag import Tag + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = '__all__' diff --git a/apps/blog/signals.py b/apps/blog/signals.py new file mode 100644 index 0000000..dd186bb --- /dev/null +++ b/apps/blog/signals.py @@ -0,0 +1,10 @@ +from django.db.models.signals import pre_delete +from django.dispatch import receiver +from apps.blog.models.image import Image +import os + +@receiver(pre_delete, sender=Image) +def delete_image_file(sender, instance, **kwargs): + if instance.image_file: + if os.path.isfile(instance.image_file.path): + os.remove(instance.image_file.path) diff --git a/apps/blog/tasks.py b/apps/blog/tasks.py new file mode 100644 index 0000000..4f3bdbe --- /dev/null +++ b/apps/blog/tasks.py @@ -0,0 +1,7 @@ +from celery import shared_task +from apps.blog.models.image import Image + +@shared_task +def handle_image_upload(image_id): + image = Image.objects.get(id=image_id) + diff --git a/apps/blog/test/__init__.py b/apps/blog/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/test/test_tag_model.py b/apps/blog/test/test_tag_model.py new file mode 100644 index 0000000..ebc982d --- /dev/null +++ b/apps/blog/test/test_tag_model.py @@ -0,0 +1,16 @@ + +from django.test import TestCase +from apps.blog.models.tag import Tag +from apps.blog.models.category import Category +from apps.blog.models.author import Author +from apps.blog.models.blog import Blog +from apps.blog.models.image import Image + +class TagModelTest(TestCase): + def setUp(self): + Tag.objects.create(name="Test Tag") + + def test_tag_creation(self): + tag = Tag.objects.get(name="Test Tag") + self.assertEqual(tag.name, "Test Tag") + diff --git a/apps/blog/views/__init__.py b/apps/blog/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/views/author.py b/apps/blog/views/author.py new file mode 100644 index 0000000..70c7c82 --- /dev/null +++ b/apps/blog/views/author.py @@ -0,0 +1,7 @@ +from rest_framework import generics +from apps.blog.models.author import Author +from apps.blog.serializers.author_serializer import AuthorSerializer + +class AuthorListView(generics.ListCreateAPIView): + queryset = Author.objects.all() + serializer_class = AuthorSerializer diff --git a/apps/blog/views/blog.py b/apps/blog/views/blog.py new file mode 100644 index 0000000..461e24e --- /dev/null +++ b/apps/blog/views/blog.py @@ -0,0 +1,17 @@ +from rest_framework import generics, permissions +from apps.blog.models.blog import Blog +from apps.blog.serializers.blog_serializer import BlogSerializer, BlogCreateUpdateSerializer + + +class BlogListCreateView(generics.ListCreateAPIView): + queryset = Blog.objects.all() + serializer_class = BlogCreateUpdateSerializer + + def get_permissions(self): + if self.request.method == 'POST': + self.permission_classes = [permissions.IsAuthenticated] + return super().get_permissions() + +class BlogDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = Blog.objects.all() + serializer_class = BlogSerializer diff --git a/apps/blog/views/category.py b/apps/blog/views/category.py new file mode 100644 index 0000000..2c22d00 --- /dev/null +++ b/apps/blog/views/category.py @@ -0,0 +1,7 @@ +from rest_framework import generics +from apps.blog.models.category import Category +from apps.blog.serializers.category_serializer import CategorySerializer + +class CategoryListView(generics.ListCreateAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer diff --git a/apps/blog/views/image.py b/apps/blog/views/image.py new file mode 100644 index 0000000..fafccb5 --- /dev/null +++ b/apps/blog/views/image.py @@ -0,0 +1,17 @@ +from rest_framework import generics +from apps.blog.models.image import Image +from apps.blog.serializers.image_serializer import ImageSerializer +from apps.blog.tasks import handle_image_upload + + +class ImageListView(generics.ListCreateAPIView): + queryset = Image.objects.all() + serializer_class = ImageSerializer + +class ImageCreateView(generics.CreateAPIView): + queryset = Image.objects.all() + serializer_class = ImageSerializer + + def perform_create(self, serializer): + instance = serializer.save() + handle_image_upload.delay(instance.id) diff --git a/apps/blog/views/index.py b/apps/blog/views/index.py new file mode 100644 index 0000000..bf607f4 --- /dev/null +++ b/apps/blog/views/index.py @@ -0,0 +1,5 @@ +from django.http import HttpResponse + + +def index(request): + return HttpResponse("Hello, world! This is the django cameroon page.") diff --git a/apps/blog/views/post.py b/apps/blog/views/post.py new file mode 100644 index 0000000..1608631 --- /dev/null +++ b/apps/blog/views/post.py @@ -0,0 +1,64 @@ +from rest_framework import generics, permissions +from apps.blog.models.blog import Blog +from apps.blog.serializers.blog_serializer import BlogSerializer, BlogCreateUpdateSerializer +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + + +class PostList(generics.ListCreateAPIView): + queryset = Blog.objects.all() + + def get_serializer_class(self): + if self.request.method == 'POST': + return BlogCreateUpdateSerializer + return BlogSerializer + + def get_permissions(self): + if self.request.method == 'POST': + self.permission_classes = [permissions.IsAuthenticated] + return super().get_permissions() + + @swagger_auto_schema( + operation_description="Retrieve a list of blog posts", + responses={200: BlogSerializer(many=True)} + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + @swagger_auto_schema( + operation_description="Create a new blog post", + request_body=BlogCreateUpdateSerializer, + responses={201: BlogSerializer} + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + +class PostDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = Blog.objects.all() + + def get_serializer_class(self): + if self.request.method in ['PUT', 'PATCH']: + return BlogCreateUpdateSerializer + return BlogSerializer + + @swagger_auto_schema( + operation_description="Retrieve a blog post", + responses={200: BlogSerializer} + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + @swagger_auto_schema( + operation_description="Update a blog post", + request_body=BlogCreateUpdateSerializer, + responses={200: BlogSerializer} + ) + def put(self, request, *args, **kwargs): + return super().put(request, *args, **kwargs) + + @swagger_auto_schema( + operation_description="Delete a blog post", + responses={204: openapi.Response(description="No Content")} + ) + def delete(self, request, *args, **kwargs): + return super().delete(request, *args, **kwargs) diff --git a/apps/blog/views/tag.py b/apps/blog/views/tag.py new file mode 100644 index 0000000..94f2bbc --- /dev/null +++ b/apps/blog/views/tag.py @@ -0,0 +1,7 @@ +from rest_framework import generics +from apps.blog.models.tag import Tag +from apps.blog.serializers.tag_serializer import TagSerializer + +class TagListView(generics.ListCreateAPIView): + queryset = Tag.objects.all() + serializer_class = TagSerializer \ No newline at end of file diff --git a/apps/events/__init__.py b/apps/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/admin.py b/apps/events/admin.py new file mode 100644 index 0000000..d140594 --- /dev/null +++ b/apps/events/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from apps.events.models import ( + Event, EventCity, EventRegion, + EventVenue, EventTag, Reservation, + Speaker, SpeakerSocialMedia, SpeakerSpeciality, + AvailableSocialMedia, +) + +admin.site.register(Event) +admin.site.register(EventCity) +admin.site.register(EventRegion) +admin.site.register(EventVenue) +admin.site.register(EventTag) +admin.site.register(Reservation) +admin.site.register(Speaker) +admin.site.register(SpeakerSocialMedia) +admin.site.register(SpeakerSpeciality) +admin.site.register(AvailableSocialMedia) diff --git a/apps/events/apps.py b/apps/events/apps.py new file mode 100644 index 0000000..0ee128e --- /dev/null +++ b/apps/events/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +class EventsConfig(AppConfig): + name = 'apps.events' + + def ready(self): + import apps.events.signals \ No newline at end of file diff --git a/apps/events/migrations/0001_initial.py b/apps/events/migrations/0001_initial.py new file mode 100644 index 0000000..8e1ed8e --- /dev/null +++ b/apps/events/migrations/0001_initial.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2.4 on 2025-07-06 16:00 + +import apps.events.models.constants +import utils.main +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AvailableSocialMedia', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of the social media platform', max_length=50, verbose_name='Social Media Platform Name')), + ('link', models.URLField(help_text='Link to the social media platform', verbose_name='Social Media Platform Link')), + ('active', models.BooleanField(default=True, help_text='Is the social media platform active?', verbose_name='Is Active')), + ], + options={ + 'verbose_name': 'Available Social Media', + 'verbose_name_plural': 'Available Social Media', + 'db_table': 'available_social_media', + }, + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.CharField(choices=[('Workshops', 'Workshops'), ('Talks', 'Talks')], default=apps.events.models.constants.EventCategory['WORKSHOPS'], help_text='The category of the event', max_length=50, verbose_name='Event category')), + ('for_community', models.CharField(choices=[('Django Cameroon', 'Django Cameroon'), ('Django Girls Cameroon', 'Django Girls Cameroon')], default=apps.events.models.constants.Community['DJANGO_CAMEROON'], help_text='The community the event is for', max_length=50, verbose_name='Event community')), + ('title', models.CharField(help_text='The title of the event', max_length=100, verbose_name='Event title')), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.TextField(help_text='The description of the event', verbose_name='Event description')), + ('date', models.DateTimeField(help_text='The date of the event', verbose_name='Event date')), + ('thumbnail', models.URLField(blank=True, help_text='The thumbnail of the event', null=True, verbose_name='Event thumbnail')), + ('type', models.CharField(choices=[('Online', 'Online'), ('In-person', 'In-person'), ('Hybrid', 'Hybrid')], default=apps.events.models.constants.EventType['IN_PERSON'], help_text='The type of the event', max_length=50, verbose_name='Event type')), + ('level', models.CharField(blank=True, max_length=50, null=True)), + ('published', models.BooleanField(default=False, help_text='Whether the event is published', verbose_name='Event published')), + ], + options={ + 'verbose_name': 'Event', + 'verbose_name_plural': 'Events', + 'db_table': 'events', + }, + ), + migrations.CreateModel( + name='EventCity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='EventRegion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='EventTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('color', models.CharField(max_length=10)), + ], + options={ + 'verbose_name': 'Event tag', + 'verbose_name_plural': 'Event tags', + 'db_table': 'event_tags', + }, + ), + migrations.CreateModel( + name='EventVenue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='Reservation', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('check_in', models.BooleanField(default=False)), + ], + options={ + 'verbose_name': 'Reservation', + 'verbose_name_plural': 'Reservations', + 'db_table': 'reservations', + }, + ), + migrations.CreateModel( + name='Speaker', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text="Speaker's full name", max_length=50, verbose_name='Full Name')), + ('photo', models.URLField(default='https://via.placeholder.com/150', help_text="Speaker's photo", verbose_name='Photo URL')), + ('bio', models.TextField(blank=True, help_text="Speaker's bio", null=True, verbose_name='Biography')), + ('slug', models.SlugField(blank=True, null=True, unique=True)), + ], + options={ + 'verbose_name': 'Speaker', + 'verbose_name_plural': 'Speakers', + 'db_table': 'speakers', + }, + ), + migrations.CreateModel( + name='SpeakerSocialMedia', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('handle', models.CharField(help_text='Social media handle', max_length=50, verbose_name='Handle')), + ], + options={ + 'verbose_name': 'Speaker Social Media', + 'verbose_name_plural': 'Speaker Social Media', + 'db_table': 'speaker_social_media', + 'ordering': ['speaker', 'platform'], + }, + ), + migrations.CreateModel( + name='SpeakerSpeciality', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of the speciality', max_length=50, verbose_name='Speciality Name')), + ], + options={ + 'verbose_name': 'Speaker Speciality', + 'verbose_name_plural': 'Speaker Specialities', + 'db_table': 'speaker_specialities', + }, + ), + ] diff --git a/apps/events/migrations/0002_initial.py b/apps/events/migrations/0002_initial.py new file mode 100644 index 0000000..e5c7e64 --- /dev/null +++ b/apps/events/migrations/0002_initial.py @@ -0,0 +1,120 @@ +# Generated by Django 5.2.4 on 2025-07-06 16:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='event', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='eventcity', + name='region', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.eventregion'), + ), + migrations.AddField( + model_name='event', + name='tags', + field=models.ManyToManyField(default=None, help_text='The tags for the event', related_name='events', to='events.eventtag', verbose_name='Event tags'), + ), + migrations.AddField( + model_name='eventvenue', + name='city', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.eventcity'), + ), + migrations.AddField( + model_name='event', + name='location', + field=models.ForeignKey(help_text='The location of the event', on_delete=django.db.models.deletion.CASCADE, to='events.eventvenue', verbose_name='Event location'), + ), + migrations.AddField( + model_name='reservation', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservation', + name='for_event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='events.event', verbose_name='event'), + ), + migrations.AddField( + model_name='reservation', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='speaker', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='speaker', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='event', + name='speakers', + field=models.ManyToManyField(help_text='The speakers at the event', related_name='events', to='events.speaker', verbose_name='Event speakers'), + ), + migrations.AddField( + model_name='speakersocialmedia', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='speakersocialmedia', + name='platform', + field=models.ForeignKey(help_text='Social media platform', on_delete=django.db.models.deletion.CASCADE, to='events.availablesocialmedia', verbose_name='Social Media Platform'), + ), + migrations.AddField( + model_name='speakersocialmedia', + name='speaker', + field=models.ForeignKey(help_text="Speaker's social media", on_delete=django.db.models.deletion.CASCADE, related_name='social_media', to='events.speaker', verbose_name='Speaker'), + ), + migrations.AddField( + model_name='speakersocialmedia', + name='updated_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='speaker', + name='speciality', + field=models.ForeignKey(blank=True, help_text="Speaker's speciality", null=True, on_delete=django.db.models.deletion.CASCADE, to='events.speakerspeciality', verbose_name='Speciality'), + ), + migrations.AlterUniqueTogether( + name='reservation', + unique_together={('for_event', 'user')}, + ), + migrations.AlterUniqueTogether( + name='event', + unique_together={('title', 'date', 'location')}, + ), + migrations.AlterUniqueTogether( + name='speakersocialmedia', + unique_together={('speaker', 'platform')}, + ), + ] diff --git a/apps/events/migrations/__init__.py b/apps/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/models/__init__.py b/apps/events/models/__init__.py new file mode 100644 index 0000000..753ea5f --- /dev/null +++ b/apps/events/models/__init__.py @@ -0,0 +1,3 @@ +from .event import Event, EventCity, EventRegion, EventVenue, EventTag +from .reservation import Reservation +from .speaker import Speaker, SpeakerSocialMedia, SpeakerSpeciality, AvailableSocialMedia diff --git a/apps/events/models/constants.py b/apps/events/models/constants.py new file mode 100644 index 0000000..f56feaa --- /dev/null +++ b/apps/events/models/constants.py @@ -0,0 +1,35 @@ +from enum import Enum +from typing import Tuple + + +class EventCategory(str, Enum): + """ + Django Cameroon Event Categories + """ + + WORKSHOPS = "Workshops" + TALKS = "Talks" + + +class EventType(str, Enum): + """ + Django Cameroon Event Types + """ + + ONLINE = "Online" + IN_PERSON = "In-person" + HYBRID = "Hybrid" + + +class Community(str, Enum): + DJANGO_CAMEROON = "Django Cameroon" + DJANGO_GIRLS_CAMEROON = "Django Girls Cameroon" + + +def build_tuple_types(enum_type) -> Tuple: + return tuple([(item.value, item.value) for item in enum_type]) + + +EVENT_CATEGORIES = build_tuple_types(EventCategory) +EVENT_TYPES = build_tuple_types(EventType) +COMMUNITIES = build_tuple_types(Community) diff --git a/apps/events/models/event.py b/apps/events/models/event.py new file mode 100644 index 0000000..f9582ce --- /dev/null +++ b/apps/events/models/event.py @@ -0,0 +1,107 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.events.models.constants import ( + COMMUNITIES, EVENT_CATEGORIES, + EVENT_TYPES, Community, + EventCategory, EventType, +) +from apps.users.models.base_model import BaseModel + + +class EventRegion(models.Model): + name = models.CharField(max_length=50) + + +class EventCity(models.Model): + name = models.CharField(max_length=50) + region = models.ForeignKey(EventRegion, on_delete=models.CASCADE) + + +class EventVenue(models.Model): + name = models.CharField(max_length=50) + city = models.ForeignKey(EventCity, on_delete=models.CASCADE) + + +class Event(BaseModel): + category = models.CharField( + max_length=50, choices=EVENT_CATEGORIES, + default=EventCategory.WORKSHOPS, help_text=_("The category of the event"), + verbose_name=_("Event category"), + ) + for_community = models.CharField( + max_length=50, choices=COMMUNITIES, + default=Community.DJANGO_CAMEROON, help_text=_("The community the event is for"), + verbose_name=_("Event community"), + ) + title = models.CharField( + max_length=100, help_text=_("The title of the event"), + verbose_name=_("Event title"), + ) + slug = models.SlugField(max_length=100, unique=True) + description = models.TextField( + verbose_name=_("Event description"), + help_text=_("The description of the event"), + ) + location = models.ForeignKey( + EventVenue, on_delete=models.CASCADE, + verbose_name=_("Event location"), help_text=_("The location of the event"), + ) + date = models.DateTimeField( + verbose_name=_("Event date"), help_text=_("The date of the event"), + ) + thumbnail = models.URLField( + null=True, blank=True, + verbose_name=_("Event thumbnail"), help_text=_("The thumbnail of the event"), + ) + type = models.CharField( + max_length=50, choices=EVENT_TYPES, + default=EventType.IN_PERSON, help_text=_("The type of the event"), + verbose_name=_("Event type"), + ) + speakers = models.ManyToManyField( + 'Speaker', related_name="events", + verbose_name=_("Event speakers"), help_text=_("The speakers at the event"), + ) + tags = models.ManyToManyField( + "EventTag", related_name="events", default=None, + verbose_name=_("Event tags"), help_text=_("The tags for the event"), + ) + level = models.CharField( + max_length=50, null=True, blank=True, + ) + published = models.BooleanField( + default=False, help_text=_("Whether the event is published"), + verbose_name=_("Event published"), + ) + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.title.lower().replace(" ", "-") + original_slug = self.slug + counter = 1 + while Event.objects.filter(slug=self.slug).exists(): + self.slug = f"{original_slug}-{counter}" + counter += 1 + super().save(*args, **kwargs) + + class Meta: + db_table = "events" + verbose_name = _("Event") + verbose_name_plural = _("Events") + unique_together = ("title", "date", "location") + +class EventTag(models.Model): + name = models.CharField(max_length=50) + color = models.CharField(max_length=10) + + def __str__(self): + return self.name + + class Meta: + db_table = "event_tags" + verbose_name = _("Event tag") + verbose_name_plural = _("Event tags") diff --git a/apps/events/models/partners.py b/apps/events/models/partners.py new file mode 100644 index 0000000..5237fd8 --- /dev/null +++ b/apps/events/models/partners.py @@ -0,0 +1,26 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.users.models import BaseModel + + +class Partner(BaseModel): + name = models.CharField( + max_length=255, unique=True, + help_text=_("The name of the partner"), verbose_name=_("Name") + ) + logo = models.URLField( + help_text=_("The logo of the partner"), verbose_name=_("Logo") + ) + about = models.TextField( + help_text=_("About the partner"), verbose_name=_("About") + ) + website = models.URLField( + help_text=_("The website of the partner"), verbose_name=_("Website") + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Partner") + verbose_name_plural = _("Partners") \ No newline at end of file diff --git a/apps/events/models/projects.py b/apps/events/models/projects.py new file mode 100644 index 0000000..16d16d4 --- /dev/null +++ b/apps/events/models/projects.py @@ -0,0 +1,28 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.users.models import BaseModel + + +class Event(BaseModel): + """ + Event model + """ + title = models.CharField(max_length=50, verbose_name=_("Title")) + description = models.TextField(verbose_name=_("Description")) + date = models.DateField(verbose_name=_("Date")) + maintainers = models.ManyToManyField( + "users.User", verbose_name=_("Maintainers"), + help_text=_("Maintainers of the event"), related_name="maintained_events", + ) + github_link = models.URLField( + null=True, blank=True, verbose_name=_("GitHub Link"), + help_text=_("Link to the GitHub repository"), + ) + + class Meta: + db_table = "events" + verbose_name = _("Event") + verbose_name_plural = _("Events") + +# Gallery to be added here later from: https://developers.google.com/photos/library/guides/overview diff --git a/apps/events/models/reservation.py b/apps/events/models/reservation.py new file mode 100644 index 0000000..cf9a393 --- /dev/null +++ b/apps/events/models/reservation.py @@ -0,0 +1,25 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.events.models import Event +from apps.users.models import BaseModel + + +class Reservation(BaseModel): + for_event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="reservations", + verbose_name=_("event"), + ) + user = models.ForeignKey('users.User', on_delete=models.CASCADE) + check_in = models.BooleanField(default=False) + + def __str__(self): + return f"{self.user.email} -> {self.for_event.title}" + + class Meta: + db_table = "reservations" + verbose_name = _("Reservation") + verbose_name_plural = _("Reservations") + unique_together = ("for_event", "user") diff --git a/apps/events/models/speaker.py b/apps/events/models/speaker.py new file mode 100644 index 0000000..79f8500 --- /dev/null +++ b/apps/events/models/speaker.py @@ -0,0 +1,106 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.users.models import BaseModel + + +class Speaker(BaseModel): + """ + Speaker model + """ + name = models.CharField( + max_length=50, verbose_name="Full Name", + help_text=_("Speaker's full name"), + ) + photo = models.URLField( + null=False, default="https://via.placeholder.com/150", + help_text=_("Speaker's photo"), verbose_name=_("Photo URL"), + ) + bio = models.TextField( + help_text=_("Speaker's bio"), verbose_name=_("Biography"), + null=True, blank=True, + ) + speciality = models.ForeignKey( + "SpeakerSpeciality", on_delete=models.CASCADE, + help_text=_("Speaker's speciality"), verbose_name=_("Speciality"), + null=True, blank=True, + ) + slug = models.SlugField(unique=True, null=True, blank=True) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.name.lower().replace(" ", "-") + self.active = True + super().save(*args, **kwargs) + + class Meta: + db_table = "speakers" + verbose_name = _("Speaker") + verbose_name_plural = _("Speakers") + + +class AvailableSocialMedia(models.Model): + """ + Available social media platforms + """ + name = models.CharField( + max_length=50, help_text="Name of the social media platform", + verbose_name="Social Media Platform Name", + ) + link = models.URLField( + help_text="Link to the social media platform", + verbose_name="Social Media Platform Link", + ) + active = models.BooleanField( + default=True, help_text="Is the social media platform active?", + verbose_name="Is Active", + ) + + class Meta: + db_table = "available_social_media" + verbose_name = _("Available Social Media") + verbose_name_plural = _("Available Social Media") + + +class SpeakerSpeciality(models.Model): + """ + Speaker specialities + """ + name = models.CharField( + max_length=50, help_text="Name of the speciality", + verbose_name="Speciality Name", + ) + + class Meta: + db_table = "speaker_specialities" + verbose_name = _("Speaker Speciality") + verbose_name_plural = _("Speaker Specialities") + + +class SpeakerSocialMedia(BaseModel): + speaker = models.ForeignKey( + Speaker, on_delete=models.CASCADE, related_name="social_media", + verbose_name=_("Speaker"), help_text=_("Speaker's social media"), + ) + platform = models.ForeignKey( + AvailableSocialMedia, on_delete=models.CASCADE, + verbose_name=_("Social Media Platform"), + help_text=_("Social media platform"), + ) + handle = models.CharField( + max_length=50, verbose_name=_("Handle"), help_text=_("Social media handle"), + ) + + def save(self, *args, **kwargs): + self.active = self.platform.active + super().save(*args, **kwargs) + + class Meta: + db_table = "speaker_social_media" + unique_together = ("speaker", "platform") + verbose_name = _("Speaker Social Media") + verbose_name_plural = _("Speaker Social Media") + ordering = ["speaker", "platform"] diff --git a/apps/events/paginators.py b/apps/events/paginators.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/routes/__init__.py b/apps/events/routes/__init__.py new file mode 100644 index 0000000..6c9c03b --- /dev/null +++ b/apps/events/routes/__init__.py @@ -0,0 +1 @@ +from apps.events.routes.api import * diff --git a/apps/events/routes/api.py b/apps/events/routes/api.py new file mode 100644 index 0000000..ec55a80 --- /dev/null +++ b/apps/events/routes/api.py @@ -0,0 +1,12 @@ +from rest_framework.routers import SimpleRouter + +from apps.events.views.event import EventViewSet +from apps.events.views.reservation import ReservationViewSet +from apps.events.views.speaker import SpeakerViewSet + +router = SimpleRouter() +router.register(r"speakers", SpeakerViewSet) +router.register(r"events", EventViewSet, basename="events") +router.register(r"reservations", ReservationViewSet, basename="reservations") + +urlpatterns = router.urls diff --git a/apps/events/routes/extra.py b/apps/events/routes/extra.py new file mode 100644 index 0000000..47b3c05 --- /dev/null +++ b/apps/events/routes/extra.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.events.views.uploader import FileUploadView + +urlpatterns = [ + path('upload/', FileUploadView.as_view(), name='file-upload'), +] diff --git a/apps/events/serializers/__init__.py b/apps/events/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/serializers/event_serializer.py b/apps/events/serializers/event_serializer.py new file mode 100644 index 0000000..8fea922 --- /dev/null +++ b/apps/events/serializers/event_serializer.py @@ -0,0 +1,85 @@ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema_field, OpenApiTypes +from rest_framework import serializers + +from apps.events.models import Event, Speaker, EventTag, EventVenue +from apps.events.serializers.speaker_serializer import SpeakerSerializer + + +class EventSerializer(serializers.ModelSerializer): + speakers = serializers.SerializerMethodField() + tags = serializers.ListField(child=serializers.CharField(), required=False) + + class Meta: + model = Event + exclude = ("active", "level",) + + @extend_schema_field(OpenApiTypes.STR) + def get_speakers(self, event): + speakers = Speaker.objects.filter(id__in=event.speakers.values_list('id', flat=True)) + return SpeakerSerializer(speakers, many=True).data + + def to_representation(self, instance): + data = super().to_representation(instance) + data['tags'] = [tag.name for tag in instance.tags.all()] + data['speakers'] = [SpeakerSerializer(speaker).data for speaker in instance.speakers.all()] + return data + + +class CreateEventInputSerializer(serializers.ModelSerializer): + """ + Serializer for creating an event. + This serializer is used to validate the input data. + """ + tags = serializers.ListField(child=serializers.CharField(), required=False) + speakers = serializers.ListField(child=serializers.CharField(), required=False) + thumbnail = serializers.ImageField(required=False) + + class Meta: + model = Event + exclude = ("created_by", "id", "active", "slug", "level",) + + def validate(self, data): + if not EventVenue.objects.filter(id=data["location"].id).exists(): + raise serializers.ValidationError(_("Invalid location ID.")) + return data + + def validate_tags(self, tags): + validated_tags = [] + for tag in tags: + tag_obj, created = EventTag.objects.get_or_create(name=tag) + validated_tags.append(tag_obj) + return validated_tags + + def validate_speakers(self, speaker_ids): + speakers = Speaker.objects.filter(id__in=speaker_ids) + if len(speakers) != len(speaker_ids): + raise serializers.ValidationError( + _("One or more speaker IDs are invalid.") + ) + return list(speakers) + + def create(self, validated_data): + tags = validated_data.pop('tags', []) + speakers = validated_data.pop('speakers', []) + event = super().create(validated_data) + event.tags.set(tags) + event.speakers.set(speakers) + return event + + def to_representation(self, instance): + data = super().to_representation(instance) + data['tags'] = [tag.name for tag in instance.tags.all()] + data['speakers'] = [speaker.name for speaker in instance.speakers.all()] + return data + + +class CreateEventSerializer(serializers.ModelSerializer): + """ + Serializer for creating an event. + This serializer is used to create the event. + """ + + class Meta: + model = Event + exclude = ("id",) diff --git a/apps/events/serializers/reservation_serializer.py b/apps/events/serializers/reservation_serializer.py new file mode 100644 index 0000000..f6eab3e --- /dev/null +++ b/apps/events/serializers/reservation_serializer.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from apps.events.models.reservation import Reservation +from apps.users.serializers.general_serializers import UserMinSerializer + + +class ReservationSerializer(serializers.ModelSerializer): + class Meta: + model = Reservation + fields = "__all__" + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation["user"] = UserMinSerializer(instance.user).data + return representation + + +class CreateReservationInputSerializer(serializers.ModelSerializer): + """ + Serializer for creating a reservation. + This serializer is used to validate the input data. + """ + + class Meta: + model = Reservation + exclude = ("check_in", "user") + + +class CreateReservationSerializer(serializers.ModelSerializer): + """ + Serializer for creating a reservation. + This serializer is used to create the reservation. + """ + + class Meta: + model = Reservation + exclude = ("check_in",) + + def validate(self, data): + # Check if the user has already made a reservation for the event + if Reservation.objects.filter( + for_event=data["for_event"], user=data["user"] + ).exists(): + raise serializers.ValidationError( + "You have already made a reservation for this event." + ) + return data diff --git a/apps/events/serializers/speaker_serializer.py b/apps/events/serializers/speaker_serializer.py new file mode 100644 index 0000000..f9d186f --- /dev/null +++ b/apps/events/serializers/speaker_serializer.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from apps.events.models.speaker import Speaker + + +class SpeakerSerializer(serializers.ModelSerializer): + class Meta: + model = Speaker + fields = "__all__" + read_only_fields = ("id", "last_updated_by") + + +class SpeakerWithLastUpdatedBySerializer(serializers.ModelSerializer): + + class Meta: + model = Speaker + fields = "__all__" + read_only_fields = ("id",) diff --git a/apps/events/serializers/upload_serializer.py b/apps/events/serializers/upload_serializer.py new file mode 100644 index 0000000..1930a62 --- /dev/null +++ b/apps/events/serializers/upload_serializer.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class UploadSerializer(serializers.Serializer): + file = serializers.URLField() diff --git a/apps/events/signals/__init__.py b/apps/events/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/signals/main.py b/apps/events/signals/main.py new file mode 100644 index 0000000..05e2954 --- /dev/null +++ b/apps/events/signals/main.py @@ -0,0 +1,10 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from apps.events.models import Speaker, SpeakerSocialMedia + +@receiver(post_save, sender=Speaker) +def update_speaker_social_media_active_status(sender, instance, **kwargs): + social_media_accounts = SpeakerSocialMedia.objects.filter(speaker=instance) + for account in social_media_accounts: + account.active = instance.active + account.save() \ No newline at end of file diff --git a/apps/events/test/__init__.py b/apps/events/test/__init__.py new file mode 100644 index 0000000..46b134b --- /dev/null +++ b/apps/events/test/__init__.py @@ -0,0 +1 @@ +˙ţ \ No newline at end of file diff --git a/apps/events/views/__init__.py b/apps/events/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/views/event.py b/apps/events/views/event.py new file mode 100644 index 0000000..d4d0943 --- /dev/null +++ b/apps/events/views/event.py @@ -0,0 +1,188 @@ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema, OpenApiResponse +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework import status, serializers +from rest_framework.decorators import action +from rest_framework.parsers import JSONParser +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.events.models.event import Event +from apps.events.serializers.event_serializer import ( + CreateEventInputSerializer, + EventSerializer, +) +from apps.events.serializers.reservation_serializer import ReservationSerializer +from mixins.api_response_mixin import APIResponseMixin + + +class EventViewSet(ModelViewSet, APIResponseMixin): + queryset = Event.objects.all().select_related('created_by', 'updated_by') + authentication_classes = [OAuth2Authentication] + http_method_names = ["get", "post", "put", "delete"] + parser_classes = [JSONParser] + + def get_serializer_class(self): + if self.action in ["list"]: + return EventSerializer + return EventSerializer + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + @extend_schema( + summary="Get all events", + operation_id="get_events", + description="Get all events.", + responses={ + 200: OpenApiResponse( + response=EventSerializer(many=True), + description=_("List of events"), + ) + }, + tags=["Events"], + ) + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + return self.paginated_response( + request=request, + queryset=queryset, + serializer_class=EventSerializer, + message=_("List of events"), + status_code=status.HTTP_200_OK, + ) + + @extend_schema( + summary="Create an event", + operation_id="create_event", + description="Create an event.", + request=CreateEventInputSerializer, + responses={ + 201: OpenApiResponse( + response=EventSerializer, + description=_("Event created successfully") + ) + }, + tags=["Events"], + ) + def create(self, request, *args, **kwargs): + create_event_serializer = CreateEventInputSerializer(data=request.data) + create_event_serializer.is_valid(raise_exception=True) + event = create_event_serializer.save(created_by=request.user, updated_by=request.user) + return self.success( + message=_("Event created successfully"), + data=EventSerializer(event).data, + status_code=status.HTTP_201_CREATED, + ) + + @extend_schema( + summary="Get event details", + operation_id="get_event_details", + description="Get event details.", + responses={ + 200: OpenApiResponse( + response=EventSerializer, + description=_("Event details") + ) + }, + tags=["Events"], + ) + def retrieve(self, request, *args, **kwargs): + event = self.get_queryset().select_related( + 'created_by', 'updated_by' + ).get(pk=kwargs['pk']) + serializer = EventSerializer(event) + return self.success( + message=_("Event details"), + status_code=status.HTTP_200_OK, + data=serializer.data, + ) + + @extend_schema( + summary="Update an event", + operation_id="update_event", + description="Update an event.", + responses={ + 200: OpenApiResponse( + response=EventSerializer, + description=_("Event updated successfully") + ) + }, + tags=["Events"], + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary="Delete an event", + operation_id="delete_event", + description="Delete an event.", + responses={204: None}, + tags=["Events"], + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary="Publish an event", + operation_id="publish_event", + description="Publish an event.", + responses={ + 200: OpenApiResponse( + description=_("Event published successfully") + ) + }, + tags=["Events"], + ) + @action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated]) + def publish_event(self, request, event_id: str) -> Response: + """ + Publish an event + """ + try: + event = Event.objects.only('id').get(id=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError(_("Event not found")) + event.published = True + event.save(update_fields=['published']) + + return self.success( + message=_("Event published successfully"), + status_code=status.HTTP_200_OK, + ) + + @extend_schema( + summary="Get all reservations for a specific event", + operation_id="get_event_reservations", + description="Get all reservations for a specific event.", + responses={ + 200: OpenApiResponse( + response=ReservationSerializer(many=True), + description=_("List of reservations") + ) + }, + tags=["Events"], + ) + @action(detail=False, methods=["GET"], permission_classes=[IsAuthenticated]) + def retrieve_event_reservations(self, request, event_id: str) -> Response: + """ + Get all reservations for a specific event. + """ + try: + existing_event = Event.objects.prefetch_related('reservations').get(id=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError(_("Event not found")) + + reservations = existing_event.reservations.only( + 'id', 'user', 'status', 'created_at' + ) + return self.success( + message=_("List of reservations"), + status_code=status.HTTP_200_OK, + data=ReservationSerializer(reservations, many=True).data, + ) diff --git a/apps/events/views/reservation.py b/apps/events/views/reservation.py new file mode 100644 index 0000000..bd1bf2e --- /dev/null +++ b/apps/events/views/reservation.py @@ -0,0 +1,161 @@ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema, OpenApiResponse +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework import status, serializers +from rest_framework.decorators import action +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.events.models.reservation import Reservation +from apps.events.serializers.reservation_serializer import ( + CreateReservationSerializer, + ReservationSerializer, +) +from mixins.api_response_mixin import APIResponseMixin + + +class ReservationViewSet(ModelViewSet, APIResponseMixin): + queryset = Reservation.objects.all() + authentication_classes = [OAuth2Authentication] + http_method_names = ["get", "post", "put", "delete"] + parser_classes = [JSONParser] + + def get_serializer_class(self): + if self.action in ["list", "retrieve"]: + return ReservationSerializer + if self.action == "create": + return CreateReservationSerializer + return ReservationSerializer + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + @extend_schema( + summary="List all reservations", + operation_id="list_reservations", + description="List all reservations.", + tags=["Reservations"], + ) + def list(self, request, *args, **kwargs): + reservations = self.get_queryset() + return self.paginated_response( + request=request, + queryset=reservations, + serializer_class=ReservationSerializer, + message=_("Reservations listed successfully"), + status_code=status.HTTP_200_OK, + ) + + @extend_schema( + summary="Get reservation details", + operation_id="get_reservation_details", + description="Get reservation details.", + tags=["Reservations"], + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary="Update a reservation", + operation_id="update_reservation", + description="Update a reservation.", + tags=["Reservations"], + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary="Delete a reservation", + operation_id="delete_reservation", + description="Delete a reservation.", + tags=["Reservations"], + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary="Create a reservation for an event.", + operation_id="create_reservation", + description="Create a reservation for an event.", + tags=["Reservations"], + responses={ + 201: OpenApiResponse( + response=ReservationSerializer, + description=_("Reservation created successfully") + ) + }, + ) + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + request.data["user"] = request.user.id + + if serializer.is_valid(): + serializer = CreateReservationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + reservation = Reservation.objects.create(**serializer.validated_data) + + # TODO: Implement the send_email function to send an email to the user + + return self.success( + message=_("Reservation created successfully"), + status_code=status.HTTP_201_CREATED, + data=ReservationSerializer(reservation).data, + ) + else: + return self.error( + message=_("Validation error"), + status_code=status.HTTP_400_BAD_REQUEST, + errors=serializer.errors, + ) + + @extend_schema( + summary="Check in a reservation.", + operation_id="check_in_reservation", + description="Check in a reservation.", + tags=["Reservations"], + responses={ + 200: OpenApiResponse( + description=_("Reservation checked in successfully") + ) + }, + ) + @action(detail=False, methods=["POST"], permission_classes=[IsAuthenticated]) + def check_in(self, reservation_id: str) -> Response: + """ + Check in a reservation. + """ + try: + existing_reservation = Reservation.objects.get(id=reservation_id) + except Reservation.DoesNotExist: + raise serializers.ValidationError(_("Reservation not found")) + + existing_reservation.check_in = True + existing_reservation.save() + return self.success( + message=_("Reservation checked in successfully"), + status_code=status.HTTP_200_OK, + ) + + # TODO: Maybe Implement the get_reservations_stats function + + @extend_schema( + summary="Get reservation statistics", + operation_id="get_reservations_statistics", + description="Get reservation statistics.", + tags=["Reservations"], + ) + @action(detail=False, methods=["GET"], permission_classes=[IsAuthenticated]) + def get_reservations_statistics(self) -> Response: + """ + Get reservation statistics. + """ + pass diff --git a/apps/events/views/speaker.py b/apps/events/views/speaker.py new file mode 100644 index 0000000..25fe006 --- /dev/null +++ b/apps/events/views/speaker.py @@ -0,0 +1,128 @@ +from drf_spectacular.utils import extend_schema +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.viewsets import ModelViewSet + +from apps.events.models.speaker import Speaker +from apps.events.serializers.speaker_serializer import ( + SpeakerSerializer, + SpeakerWithLastUpdatedBySerializer, +) +from apps.users.serializers.general_serializers import PaginatedResponseSerializer +from mixins.api_response_mixin import APIResponseMixin + + +class SpeakerViewSet(ModelViewSet, APIResponseMixin): + """ + ViewSet for managing speakers. + """ + queryset = Speaker.objects.all() + authentication_classes = [OAuth2Authentication] + parser_classes = [JSONParser] + http_method_names = ["get", "post", "put", "delete"] + + def get_serializer_class(self): + if self.action in ["list", "retrieve"]: + return SpeakerSerializer + return SpeakerWithLastUpdatedBySerializer + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + @extend_schema( + tags=["Speakers"], + summary="Create a new speaker", + operation_id="create_speaker", + description="Create a new speaker.", + request=SpeakerWithLastUpdatedBySerializer, + responses={201: SpeakerWithLastUpdatedBySerializer}, + ) + def create(self, request, *args, **kwargs): + serializer = SpeakerWithLastUpdatedBySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return self.success( + message="Speaker created successfully", + status_code=status.HTTP_201_CREATED, + data=serializer.data, + ) + + @extend_schema( + tags=["Speakers"], + summary="List all speakers", + operation_id="list_speakers", + description="List all speakers.", + responses={ + status.HTTP_200_OK: PaginatedResponseSerializer(data_serializer_class=SpeakerSerializer), + } + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + tags=["Speakers"], + summary="Get a speaker", + operation_id="get_speaker", + description="Get a speaker.", + responses={200: SpeakerSerializer}, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + tags=["Speakers"], + summary="Update a speaker", + operation_id="update_speaker", + description="Update a speaker.", + request=SpeakerWithLastUpdatedBySerializer, + responses={200: SpeakerWithLastUpdatedBySerializer}, + ) + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = SpeakerWithLastUpdatedBySerializer( + instance, data=request.data, partial=partial + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + if getattr(instance, "_prefetched_objects_cache", None): + instance._prefetched_objects_cache = {} + + return self.success( + message="Speaker updated successfully", + status_code=status.HTTP_200_OK, + data=serializer.data, + ) + + @extend_schema( + tags=["Speakers"], + summary="Partially update a speaker", + operation_id="partial_update_speaker", + description="Partially update a speaker.", + request=SpeakerWithLastUpdatedBySerializer, + responses={200: SpeakerWithLastUpdatedBySerializer}, + ) + def partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.update(request, *args, **kwargs) + + @extend_schema( + tags=["Speakers"], + summary="Delete a speaker", + operation_id="delete_speaker", + description="Delete a speaker.", + responses={204: None}, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) diff --git a/apps/events/views/uploader.py b/apps/events/views/uploader.py new file mode 100644 index 0000000..ee6cf69 --- /dev/null +++ b/apps/events/views/uploader.py @@ -0,0 +1,41 @@ +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.http import JsonResponse +from drf_spectacular.utils import extend_schema, OpenApiResponse +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from apps.events.serializers.upload_serializer import UploadSerializer +from mixins import APIResponseMixin +from rest_framework.parsers import MultiPartParser + +class FileUploadView(APIView, APIResponseMixin): + """ + View to handle file uploads. + """ + permission_classes = [IsAuthenticated] + serializer_class = UploadSerializer + parser_classes = [MultiPartParser] + + + @extend_schema( + operation_id="Upload a file", + description="Upload a file to the server.", + tags=["File Upload"], + request=UploadSerializer, + responses={201: OpenApiResponse(description="File uploaded successfully")}, + ) + def post(self, request, *args, **kwargs): + if 'file' not in request.FILES: + return JsonResponse({'error': 'No file provided'}, status=400) + + file = request.FILES['file'] + file_name = default_storage.save(file.name, ContentFile(file.read())) + file_url = default_storage.url(file_name) + + return self.success( + message='File uploaded successfully', + data={'file_url': file_url}, + status_code=status.HTTP_201_CREATED + ) diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..d11582d --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from apps.users.models import OtpCode, User + +admin.site.register(User) +admin.site.register(OtpCode) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..f184fc8 --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' + \ No newline at end of file diff --git a/apps/users/helpers/__init__.py b/apps/users/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/helpers/auth.py b/apps/users/helpers/auth.py new file mode 100644 index 0000000..ceaad62 --- /dev/null +++ b/apps/users/helpers/auth.py @@ -0,0 +1,29 @@ +import secrets +from datetime import timedelta + +from django.contrib.auth import authenticate +from django.utils.timezone import now +from oauth2_provider.models import AccessToken, RefreshToken, Application + + +def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + +def generate_tokens(self, user): + application, _ = Application.objects.get_or_create(name="Default") + expiration_time = now() + timedelta(days=1) + + access_token = AccessToken.objects.create( + user=user, + application=application, + expires=expiration_time, + token=secrets.token_hex(16), + ) + refresh_token = RefreshToken.objects.create( + user=user, + application=application, + token=secrets.token_hex(16), + access_token=access_token, + ) + return {'access_token': access_token, 'refresh_token': refresh_token} diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..79eb3ed --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2.4 on 2025-07-06 16:00 + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import utils.main +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ('events', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('email', models.EmailField(error_messages={'unique': 'A user with that email already exists.'}, help_text='Enter a valid email address', max_length=254, unique=True, verbose_name='email address')), + ('profile_image', models.URLField(default='https://via.placeholder.com/150', help_text="URL to the user's profile image", verbose_name='Profile Image')), + ('gender', models.CharField(choices=[('Male', 'Male'), ('Female', 'Female'), ('Prefer not to say', 'Prefer not to say')], default='Male', max_length=50)), + ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('updated_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'users', + }, + ), + migrations.CreateModel( + name='OtpCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(max_length=255)), + ('otp_code', models.CharField(max_length=255)), + ('use_for', models.CharField(default='email', max_length=255)), + ('expires_at', models.DateTimeField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'OTP Code', + 'verbose_name_plural': 'OTP Codes', + 'db_table': 'otp_codes', + }, + ), + migrations.CreateModel( + name='UserSocialAccount', + fields=[ + ('id', models.UUIDField(default=utils.main.generate_uuid, editable=False, help_text='Unique identifier for this object', primary_key=True, serialize=False)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('link', models.URLField()), + ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('platform', models.ForeignKey(help_text='The social media platform', on_delete=django.db.models.deletion.CASCADE, to='events.availablesocialmedia', verbose_name='Platform')), + ('updated_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(help_text="The user's social account", on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'User Social Account', + 'verbose_name_plural': 'User Social Accounts', + 'unique_together': {('user', 'platform')}, + }, + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models/__init__.py b/apps/users/models/__init__.py new file mode 100644 index 0000000..92b91ef --- /dev/null +++ b/apps/users/models/__init__.py @@ -0,0 +1,3 @@ +from apps.users.models.base_model import BaseModel +from apps.users.models.otp_code import OtpCode +from apps.users.models.user import User diff --git a/apps/users/models/base_model.py b/apps/users/models/base_model.py new file mode 100644 index 0000000..0650e39 --- /dev/null +++ b/apps/users/models/base_model.py @@ -0,0 +1,56 @@ +from crequest.middleware import CrequestMiddleware +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from utils.main import generate_uuid + + +class BaseModel(models.Model): + """ + Abstract model that contains common fields for all models, including tracking fields + for creation and modification by users. + """ + id = models.UUIDField( + primary_key=True, + default=generate_uuid, + editable=False, + help_text=_("Unique identifier for this object") + ) + active = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="created_%(class)s_set", + editable=False, + null=True, + on_delete=models.SET_NULL + ) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="updated_%(class)s_set", + editable=False, + null=True, + on_delete=models.SET_NULL + ) + + def save(self, *args, **kwargs): + try: + request = CrequestMiddleware.get_request() + if request and request.user.is_authenticated: + user = request.user + else: + user = None + except Exception: + user = None + + if not self.created_by_id: + self.created_by = user + + self.updated_by = user + + super().save(*args, **kwargs) + + class Meta: + abstract = True diff --git a/apps/users/models/otp_code.py b/apps/users/models/otp_code.py new file mode 100644 index 0000000..c8c69f3 --- /dev/null +++ b/apps/users/models/otp_code.py @@ -0,0 +1,22 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.timezone import now + + +class OtpCode(models.Model): + object_id = models.CharField(max_length=255) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + otp_code = models.CharField(max_length=255) + use_for = models.CharField(max_length=255, default="email") + expires_at = models.DateTimeField() + + content_object = GenericForeignKey("content_type", "object_id") + + class Meta: + db_table = "otp_codes" + verbose_name = "OTP Code" + verbose_name_plural = "OTP Codes" + + def has_expired(self): + return self.expires_at < now() diff --git a/apps/users/models/user.py b/apps/users/models/user.py new file mode 100644 index 0000000..6f03777 --- /dev/null +++ b/apps/users/models/user.py @@ -0,0 +1,71 @@ +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import PermissionsMixin +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from apps.events.models.speaker import AvailableSocialMedia +from apps.users.models import BaseModel +from apps.users.models.user_manager import UserManager +from services import MailService + + +class User(BaseModel, AbstractUser, PermissionsMixin): + email = models.EmailField( + _("email address"), unique=True, + help_text=_("Enter a valid email address"), + error_messages={ + "unique": _("A user with that email already exists.") + } + ) + profile_image = models.URLField( + default="https://via.placeholder.com/150", verbose_name=_("Profile Image"), + help_text=_("URL to the user's profile image"), + ) + otp_codes = GenericRelation("users.OtpCode") + + gender = models.CharField( + max_length=50, + choices=( + ("Male", "Male"), + ("Female", "Female"), + ("Prefer not to say", "Prefer not to say"), + ), + default="Male", + ) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = UserManager() + + def send_email_otp(self) -> None: + mail_service = MailService() + mail_service.send_otp(self) + return None + + class Meta: + db_table = "users" + verbose_name = _("User") + verbose_name_plural = _("Users") + + +class UserSocialAccount(BaseModel): + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="social_accounts", + verbose_name=_("User"), help_text=_("The user's social account"), + ) + platform = models.ForeignKey( + AvailableSocialMedia, on_delete=models.CASCADE, + verbose_name=_("Platform"), help_text=_("The social media platform"), + ) + link = models.URLField() + + def save(self, *args, **kwargs): + self.active = self.platform.active + super().save(*args, **kwargs) + + class Meta: + unique_together = ("user", "platform") + verbose_name = _("User Social Account") + verbose_name_plural = _("User Social Accounts") diff --git a/apps/users/models/user_manager.py b/apps/users/models/user_manager.py new file mode 100644 index 0000000..790c856 --- /dev/null +++ b/apps/users/models/user_manager.py @@ -0,0 +1,26 @@ +from django.contrib.auth.models import BaseUserManager + +from django.utils.translation import gettext_lazy as _ + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError(_("Email is required")) + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save() + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError(_("Superuser must have is_staff=True")) + + if extra_fields.get("is_superuser") is not True: + raise ValueError(_("Superuser must have is_superuser=True")) + + return self.create_user(email, password, **extra_fields) diff --git a/apps/users/pagination.py b/apps/users/pagination.py new file mode 100644 index 0000000..f531a55 --- /dev/null +++ b/apps/users/pagination.py @@ -0,0 +1,26 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + + +class CustomPagination(PageNumberPagination): + page_size_query_param = 'page_size' + page_query_param = 'page'.lower() + max_page_size = 100 + + def get_paginated_response(self, data): + return Response({ + 'status': True, + 'message': 'Data retrieved successfully', + 'status_code': 200, + 'page': self.page.number, + 'page_size': self.page.paginator.per_page, + 'total': self.page.paginator.count, + 'pagination': { + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'count': self.page.paginator.count, + 'current_page': self.page.number, + 'total_pages': self.page.paginator.num_pages + }, + 'data': data + }) diff --git a/apps/users/permissions/__init__.py b/apps/users/permissions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/permissions/user_permissions.py b/apps/users/permissions/user_permissions.py new file mode 100644 index 0000000..94f3e2d --- /dev/null +++ b/apps/users/permissions/user_permissions.py @@ -0,0 +1,84 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class HasPermission(BasePermission): + """ + Custom permission class to check for specific permissions required by the view. + """ + + def has_permission(self, request, view): + required_permissions = getattr(view, "required_permissions", []) + exempt_methods = getattr(view, "exempt_methods", []) + + if not required_permissions: + raise Exception( + _("Required permissions not set in view. Please define 'required_permissions' attribute.") + ) + + if request.method in exempt_methods: + return True + + if not request.user.is_authenticated: + raise DRFPermissionDenied(_("Authentication credentials were not provided.")) + + for permission in required_permissions: + if not request.user.has_perm(permission): + raise DRFPermissionDenied( + _("You do not have the required permission: '{permission}' to perform this action.").format( + permission=permission) + ) + + return True + + +class HasRole(BasePermission): + """ + Custom permission class to check for specific roles required by the view. + """ + + def has_permission(self, request, view): + required_roles = getattr(view, "required_roles", []) + exempt_methods = getattr(view, "exempt_methods", []) + + if not required_roles: + raise Exception( + _("Required roles not set in view. Please define 'required_roles' attribute.") + ) + + if request.method in exempt_methods: + return True + + if not request.user.is_authenticated: + raise DRFPermissionDenied(_("Authentication credentials were not provided.")) + + for role in required_roles: + if not request.user.groups.filter(name=role).exists(): + raise DRFPermissionDenied( + _("You do not have the required role: '{role}' to perform this action.").format(role=role) + ) + + return True + + +class IsAdminOrReadOnly(BasePermission): + """ + Custom permission to allow read-only access for non-admin users. + """ + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + return request.user.is_authenticated and request.user.is_superuser + + +class IsOrganizer(BasePermission): + """ + Custom permission to check if the connected user is an organizer. + """ + + def has_permission(self, request, view): + if request.user.is_authenticated and request.user.is_staff: + return True + return False \ No newline at end of file diff --git a/apps/users/routes/__init__.py b/apps/users/routes/__init__.py new file mode 100644 index 0000000..15b6a64 --- /dev/null +++ b/apps/users/routes/__init__.py @@ -0,0 +1 @@ +from .main import * diff --git a/apps/users/routes/api.py b/apps/users/routes/api.py new file mode 100644 index 0000000..37f91fe --- /dev/null +++ b/apps/users/routes/api.py @@ -0,0 +1,14 @@ +from django.urls import re_path +from apps.users.views.auth_view import UserRegistrationView, LoginView, PasswordResetRequestView, PasswordResetConfirmationView, LogoutView +from apps.users.views.user_views import UserDetails, UpdateUserProfile + +urlpatterns = [ + re_path(r'^auth/register/$', UserRegistrationView.as_view(), name='register'), + re_path(r'^auth/login/$', LoginView.as_view(), name='login'), + re_path(r'^auth/logout/$', LogoutView.as_view(), name='logout'), + re_path(r'^auth/password/reset/$', PasswordResetRequestView.as_view(), name='password-reset'), + re_path(r'^auth/password/reset/confirm/$', PasswordResetConfirmationView.as_view(), name='password-reset-confirm'), + + re_path(r'^user/$', UserDetails.as_view(), name='user-details'), + re_path(r'^user/profile/$', UpdateUserProfile.as_view(), name='update-user-profile'), +] \ No newline at end of file diff --git a/apps/users/routes/main.py b/apps/users/routes/main.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/serializers/__init__.py b/apps/users/serializers/__init__.py new file mode 100644 index 0000000..1cd9a9b --- /dev/null +++ b/apps/users/serializers/__init__.py @@ -0,0 +1,2 @@ +from apps.users.serializers.auth_serializers import UserRegistrationSerializer, LoginSerializer, LoginResponseSerializer, PassWordResetRequestSerializer, PasswordResetConfirmationSerializer +from apps.users.serializers.general_serializers import SuccessResponseSerializer, ErrorResponseSerializer, UserSerializer \ No newline at end of file diff --git a/apps/users/serializers/auth_serializers.py b/apps/users/serializers/auth_serializers.py new file mode 100644 index 0000000..ea11115 --- /dev/null +++ b/apps/users/serializers/auth_serializers.py @@ -0,0 +1,89 @@ +import re + +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema_field, OpenApiTypes +from rest_framework import serializers + +from apps.users.serializers.general_serializers import UserSerializer + +User = get_user_model() + + +class UserRegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField( + max_length=128, + min_length=8, + write_only=True, + help_text=_('Password must be at least 8 characters long'), + ) + password_confirmation = serializers.CharField( + max_length=128, + min_length=8, + write_only=True, + help_text=_('Password must be at least 8 characters long'), + ) + + def validate_password_confirmation(self, value): + password = self.get_initial().get('password') + password_regex = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#^()\-_=+{};:,<.>])[A-Za-z\d@$!%*?&#^()\-_=+{};:,<.>]{8,16}$' + if not re.match(password_regex, password): + raise serializers.ValidationError( + _('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character'), + ) + if password != value: + raise serializers.ValidationError( + _('Passwords do not match') + ) + return value + + class Meta: + model = User + fields = ['first_name', 'last_name', 'email', 'username', 'password', 'password_confirmation'] + + def create(self, validated_data): + validated_data.pop('password_confirmation') + return User.objects.create_user(**validated_data) + + +class LoginSerializer(serializers.Serializer): + email_or_username = serializers.CharField( + required=True, write_only=True, + help_text=_('Enter your email or username') + ) + password = serializers.CharField( + required=True, write_only=True, + help_text=_('Enter your password') + ) + + +class LoginResponseSerializer(serializers.Serializer): + access_token = serializers.CharField() + refresh_token = serializers.CharField() + expires_in = serializers.SerializerMethodField() + user = UserSerializer() + + @extend_schema_field( + OpenApiTypes.STR, + ) + def get_expires_in(self, obj): + return obj.get('expires_in') + + +class PassWordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + + +class PasswordResetConfirmationSerializer(serializers.Serializer): + otp = serializers.CharField() + password = serializers.CharField( + max_length=128, + min_length=8 + ) + password_confirmation = serializers.CharField() + + def validate_password_confirmation(self, value): + password = self.get_initial().get('password') + if password != value: + raise serializers.ValidationError(_('Passwords do not match')) + return value diff --git a/apps/users/serializers/general_serializers.py b/apps/users/serializers/general_serializers.py new file mode 100644 index 0000000..071c9e0 --- /dev/null +++ b/apps/users/serializers/general_serializers.py @@ -0,0 +1,69 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +User = get_user_model() + + +class PaginationSerializer(serializers.Serializer): + next = serializers.CharField() + previous = serializers.CharField() + count = serializers.IntegerField() + current_page = serializers.IntegerField() + total_pages = serializers.IntegerField() + + +class SuccessResponseSerializer(serializers.Serializer): + status = serializers.BooleanField(default=True) + message = serializers.CharField(max_length=200) + status_code = serializers.IntegerField(default=200) + + def __init__(self, *args, **kwargs): + data_serializer_class = kwargs.pop('data_serializer_class', None) + many = kwargs.pop('many', False) + super().__init__(*args, **kwargs) + if data_serializer_class: + self.fields['data'] = data_serializer_class(many=many) + + +class PaginatedResponseSerializer(SuccessResponseSerializer): + next = serializers.CharField(required=False, allow_null=True) + previous = serializers.CharField(required=False, allow_null=True) + count = serializers.IntegerField() + page = serializers.IntegerField() + page_size = serializers.IntegerField() + total_pages = serializers.IntegerField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class ErrorResponseSerializer(serializers.Serializer): + status = serializers.BooleanField(default=False) + message = serializers.CharField(default="An error occurred") + status_code = serializers.IntegerField(default=400) + + def __init__(self, *args, **kwargs): + default_message = kwargs.pop('default_message', None) + super().__init__(*args, **kwargs) + if default_message: + self.fields['message'].default = default_message + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "id", + "email", + "username", + "profile_image", + "first_name", + "last_name", + "last_login", + ] + + +class UserMinSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "email", "username", "profile_image", "gender") diff --git a/apps/users/signals/__init__.py b/apps/users/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/signals/base.py b/apps/users/signals/base.py new file mode 100644 index 0000000..75bbc46 --- /dev/null +++ b/apps/users/signals/base.py @@ -0,0 +1,25 @@ +from crequest.middleware import CrequestMiddleware +from django.db.models.signals import pre_save +from django.dispatch import receiver + +from apps.users.models import BaseModel + + +@receiver(pre_save, sender=BaseModel) +def update_user_id(sender, instance, **kwargs): + """ + Signal to update the 'updated_by' and 'created_by' fields to the current user whenever a model instance is saved. + """ + try: + request = CrequestMiddleware.get_request() + if request and request.user.is_authenticated: + user = request.user + else: + user = None + except Exception as e: + user = None + + if not instance.created_by_id: + instance.created_by = user + + instance.updated_by = user diff --git a/apps/users/test/__init__.py b/apps/users/test/__init__.py new file mode 100644 index 0000000..46b134b --- /dev/null +++ b/apps/users/test/__init__.py @@ -0,0 +1 @@ +˙ţ \ No newline at end of file diff --git a/apps/users/views/__init__.py b/apps/users/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/views/auth_view.py b/apps/users/views/auth_view.py new file mode 100644 index 0000000..198f684 --- /dev/null +++ b/apps/users/views/auth_view.py @@ -0,0 +1,219 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema, OpenApiResponse +from oauth2_provider.models import AccessToken +from rest_framework import permissions +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView + +from apps.users.helpers.auth import generate_tokens, get_serializer +from apps.users.models import OtpCode +from apps.users.serializers import ( + UserRegistrationSerializer, SuccessResponseSerializer, + ErrorResponseSerializer, LoginSerializer, + LoginResponseSerializer, UserSerializer, + PassWordResetRequestSerializer, PasswordResetConfirmationSerializer +) +from mixins import APIResponseMixin +from utils.auth import authenticate_user + +User = get_user_model() + + +class UserRegistrationView(APIResponseMixin, APIView): + permission_classes = (permissions.AllowAny,) + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Register user", + summary="Register user", + request=UserRegistrationSerializer, + tags=["Auth"], + responses={ + 201: OpenApiResponse( + response=SuccessResponseSerializer, + description=_("User account created successfully") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("Bad request") + ), + } + ) + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + serializer.save() + + return self.success( + message=_('User account created successfully'), + status_code=status.HTTP_201_CREATED, + data={} + ) + + +class LoginView(APIResponseMixin, APIView): + serializer_class = LoginSerializer + permission_classes = [permissions.AllowAny] + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Login", + summary="Login a user", + request=LoginSerializer, + tags=['Auth'], + responses={ + 200: OpenApiResponse(response=LoginResponseSerializer, description=_("Login successfully")), + 400: OpenApiResponse(response=ErrorResponseSerializer, description=_("Invalid credentials")), + } + ) + def post(self, request, *args, **kwargs): + serializer = get_serializer(self, data=request.data) + serializer.is_valid(raise_exception=True) + user = authenticate_user(self, serializer.validated_data) + + if not user: + return self.error( + _("Invalid credentials"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + + tokens = generate_tokens(self, user) + response_data = { + "access_token": tokens['access_token'].token, + "refresh_token": tokens['refresh_token'].token, + "expires_in": tokens['access_token'].expires, + "user": UserSerializer(user).data, + } + + return self.success(_("Login successfully"), response_data, status.HTTP_200_OK) + + +class PasswordResetRequestView(APIResponseMixin, APIView): + serializer_class = PassWordResetRequestSerializer + permission_classes = [permissions.AllowAny] + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Reset Password", + summary="Reset Password", + request=PassWordResetRequestSerializer, + tags=['Auth'], + responses={ + 200: OpenApiResponse( + response=SuccessResponseSerializer, + description=_("An OTP has been sent to your email for verification") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("User with the provided email does not exist") + ), + }, + ) + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + email = serializer.validated_data['email'] + user = User.objects.filter(email=email).first() + if user is None: + return self.error( + _("User with the provided email does not exist"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + user.send_email_otp() + return self.success( + _("An OTP has been sent to your email for verification"), + status_code=status.HTTP_200_OK, + ) + + +class PasswordResetConfirmationView(APIResponseMixin, APIView): + serializer_class = PasswordResetConfirmationSerializer + permission_classes = [permissions.AllowAny] + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Reset Password Confirmation", + summary="Reset Password Confirmation", + request=PasswordResetConfirmationSerializer, + tags=['Auth'], + responses={ + 200: OpenApiResponse( + response=SuccessResponseSerializer, + description=_("Password reset successfully") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("Invalid OTP or expired OTP") + ), + }, + ) + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + otp = data['otp'] + password = data['password'] + + user_content_type = ContentType.objects.get_for_model(User) + otp_model = OtpCode.objects.filter( + otp_code=otp, + content_type=user_content_type + ).first() + if otp_model is None: + return self.error(_("Invalid OTP"), status.HTTP_400_BAD_REQUEST) + + if otp_model.has_expired(): + otp_model.delete() + return self.error( + _("OTP has expired. Make a new request"), + status.HTTP_400_BAD_REQUEST + ) + user = otp_model.content_object + + if user is None: + return self.error(_("Invalid OTP"), status.HTTP_400_BAD_REQUEST) + + user.set_password(password) + user.save() + otp_model.delete() + AccessToken.objects.filter(user=user).delete() + + return self.success( + _("Password reset successfully"), + status.HTTP_200_OK + ) + + +class LogoutView(APIResponseMixin, APIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = None + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Logout", + summary="Logout", + tags=['Auth'], + responses={ + 205: OpenApiResponse( + response=SuccessResponseSerializer, + description=_("Logout successfully") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("Error during logout") + ), + }, + ) + def post(self, request, *args, **kwargs): + user = request.user + AccessToken.objects.filter(user=user).delete() + return self.success( + _("Logout successfully"), + status.HTTP_205_RESET_CONTENT + ) diff --git a/apps/users/views/general_viewsets.py b/apps/users/views/general_viewsets.py new file mode 100644 index 0000000..339b911 --- /dev/null +++ b/apps/users/views/general_viewsets.py @@ -0,0 +1,37 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + + +class BaseModelViewSet(ModelViewSet): + """ + A base viewset that you can extend to add custom pagination handling. + """ + + def paginated_response( + self, queryset, request, + serializer_class, message="Success", + status_code=status.HTTP_200_OK, + ): + """ + Custom paginated response method. + """ + page = self.paginate_queryset(queryset) + serializer = serializer_class(page, many=True) + response_data = { + "status": True, + "message": message, + "status_code": status_code, + "page": self.paginator.page.number, + "page_size": self.paginator.page_size, + "total": self.paginator.page.paginator.count, + "pagination": { + "next": self.paginator.get_next_link(), + "previous": self.paginator.get_previous_link(), + "count": self.paginator.page.paginator.count, + "current_page": self.paginator.page.number, + "total_pages": self.paginator.page.paginator.num_pages, + }, + "results": serializer.data + } + return Response(response_data, status=status_code) diff --git a/apps/users/views/index.py b/apps/users/views/index.py new file mode 100644 index 0000000..c739e50 --- /dev/null +++ b/apps/users/views/index.py @@ -0,0 +1,25 @@ +from django.http import JsonResponse + + +def index(request): + data = { + "message": "Looks like we are up and running!", + } + return JsonResponse(data) + +def page_not_found_view(request, exception=None): + return JsonResponse({ + 'status_code': 404, + 'errors': [ + 'The resource was not found' + ] + }) + + +def server_error_view(request): + return JsonResponse({ + 'status_code': 500, + 'errors': [ + 'An error occurred while processing your request', + ] + }) diff --git a/apps/users/views/user_views.py b/apps/users/views/user_views.py new file mode 100644 index 0000000..d2f1c68 --- /dev/null +++ b/apps/users/views/user_views.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema, OpenApiResponse +from rest_framework import permissions +from rest_framework import status +from rest_framework.mixins import UpdateModelMixin +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView + +from apps.users.serializers import UserSerializer, SuccessResponseSerializer, ErrorResponseSerializer +from mixins import APIResponseMixin + +User = get_user_model() + + +class UserDetails(APIResponseMixin, APIView): + permission_classes = (permissions.IsAuthenticated,) + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Get user details", + summary="Get user details", + tags=["User"], + responses={ + 200: OpenApiResponse( + response=UserSerializer, + description=_("User details retrieved successfully") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("Bad request") + ), + } + ) + def get(self, request): + user = request.user + serializer = UserSerializer(user) + return self.success( + message=_('User details retrieved successfully'), + status_code=status.HTTP_200_OK, + data=serializer.data, + ) + + +class UpdateUserProfile(APIResponseMixin, UpdateModelMixin, APIView): + permission_classes = (permissions.IsAuthenticated,) + parser_classes = [JSONParser] + + @extend_schema( + operation_id="Update user profile", + summary="Update user profile", + tags=["User"], + request=UserSerializer, + responses={ + 200: OpenApiResponse( + response=SuccessResponseSerializer, + description=_("User profile updated successfully") + ), + 400: OpenApiResponse( + response=ErrorResponseSerializer, + description=_("Bad request") + ), + } + ) + def put(self, request): + user = request.user + serializer = UserSerializer(user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return self.success( + message=_('User profile updated successfully'), + status_code=status.HTTP_200_OK, + data=serializer.data, + ) diff --git a/config/docker/Dockerfile b/config/docker/Dockerfile new file mode 100644 index 0000000..4065772 --- /dev/null +++ b/config/docker/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y supervisor && rm -rf /var/lib/apt/lists/* + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN pip install --no-cache-dir --upgrade pip==24.2 + +COPY config/docker/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +RUN python manage.py collectstatic --noinput + +COPY config/docker/supervisord-web.conf /etc/supervisor/conf.d/ +#COPY config/docker/supervisord-celery.conf /etc/supervisor/conf.d/ + +COPY config/script/start.sh /start.sh +RUN chmod +x /start.sh + +EXPOSE 8000 + +CMD ["/start.sh"] diff --git a/config/docker/requirements.txt b/config/docker/requirements.txt new file mode 100644 index 0000000..32f0f93 --- /dev/null +++ b/config/docker/requirements.txt @@ -0,0 +1,25 @@ +beautifulsoup4==4.12.3 +boto3==1.35.0 +cfgv==3.4.0 +celery==5.4.0 +django-cors-headers==4.3.1 +django-crequest==2018.5.11 +django-debug-toolbar==4.4.6 +django-dotenv==1.4.2 +django-extensions==3.2.3 +django-filter==23.5 +django-ninja==1.3.0 +django-oauth-toolkit==2.3.0 +django-storages==1.14.4 +drf-spectacular==0.27.2 +drf-spectacular-sidecar==2024.7.1 +gunicorn==23.0.0 +identify==2.5.33 +nodeenv==1.8.0 +pillow==10.3.0 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +redis==5.0.8 +tzdata==2023.4 +virtualenv==20.25.0 +whitenoise==6.7.0 \ No newline at end of file diff --git a/config/docker/supervisord-celery.conf b/config/docker/supervisord-celery.conf new file mode 100644 index 0000000..a072506 --- /dev/null +++ b/config/docker/supervisord-celery.conf @@ -0,0 +1,11 @@ +[supervisord] +nodaemon=true +user=root + +[program:celery] +command=/home/django_admin/website_api/venv/bin/celery -A website_api worker -l info +directory=/app +autostart=true +autorestart=true +stderr_logfile=/var/log/celery.err.log +stdout_logfile=/var/log/celery.out.log diff --git a/config/docker/supervisord-web.conf b/config/docker/supervisord-web.conf new file mode 100644 index 0000000..8677d0d --- /dev/null +++ b/config/docker/supervisord-web.conf @@ -0,0 +1,11 @@ +[supervisord] +nodaemon=true +user=root + +[program:web] +command=/home/django_admin/website_api/venv/bin/gunicorn website_api.wsgi:application --bind 0.0.0.0:8000 +directory=/app +autostart=true +autorestart=true +stderr_logfile=/var/log/web.err.log +stdout_logfile=/var/log/web.out.log diff --git a/config/script/start.sh b/config/script/start.sh new file mode 100644 index 0000000..f949249 --- /dev/null +++ b/config/script/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord-web.conf & + +#/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord-celery.conf & + +wait diff --git a/documentation/main.md b/documentation/main.md new file mode 100644 index 0000000..118efdb --- /dev/null +++ b/documentation/main.md @@ -0,0 +1 @@ +The Django Cameroon main website API. diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exceptions/rest_exception.py b/exceptions/rest_exception.py new file mode 100644 index 0000000..12ad4e1 --- /dev/null +++ b/exceptions/rest_exception.py @@ -0,0 +1,159 @@ +from bs4 import BeautifulSoup +from django.core.exceptions import ( + PermissionDenied as DjangoPermissionDenied, + ValidationError as DjangoValidationError, + ObjectDoesNotExist, MultipleObjectsReturned, +) +from django.http import Http404 +from django.utils.translation import gettext as _ +from rest_framework import status +from rest_framework.exceptions import ( + ParseError, AuthenticationFailed, NotAuthenticated, + PermissionDenied as DRFPermissionDenied, NotFound, + NotAcceptable, UnsupportedMediaType, Throttled, + MethodNotAllowed, ValidationError as DRFValidationError, +) +from rest_framework.response import Response +from rest_framework.views import exception_handler + + +def rest_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response is not None: + return _handle_drf_exception(exc, response) + + return _handle_django_exception(exc) + + +def _handle_drf_exception(exc, response): + if isinstance(exc, NotFound) or isinstance(exc, Http404): + response.data = { + "status": False, + "message": _("An error occurred while processing your request."), + "errors": ["This resource was not found."], + "status_code": response.status_code, + } + return response + + errors = _extract_errors_from_response(response) + response.data = { + "status": False, + "message": _('Validation error.') if isinstance(exc, (DjangoValidationError, DRFValidationError)) else _( + 'An error occurred while processing your request.'), + "errors": errors, + "status_code": response.status_code, + } + return response + + +def _extract_errors_from_response(response): + if isinstance(response.data, dict): + return _flatten_error_dict(response.data) + elif isinstance(response.data, list): + return _flatten_error_list(response.data) + else: + return [str(response.data)] + + +def _flatten_error_dict(errors, parent_key=''): + """ + Recursively flattens nested error dictionaries into a list of strings, + removing keys and returning only the error messages. + """ + flat_errors = [] + for key, value in errors.items(): + if isinstance(value, list): + flat_errors.extend(_flatten_error_list(value)) + elif isinstance(value, dict): + flat_errors.extend(_flatten_error_dict(value)) + else: + flat_errors.append(str(value)) + return flat_errors + + +def _flatten_error_list(errors): + """ + Flattens a list of errors, removing keys and returning only the error messages. + """ + flat_errors = [] + for error in errors: + if isinstance(error, dict): + flat_errors.extend(_flatten_error_dict(error)) + else: + flat_errors.append(str(error)) + return flat_errors + + +def _handle_django_exception(exc): + status_code = _get_status_code(exc) + message = _get_default_message(exc) + errors = _get_errors(exc, message) + + return Response({ + "status": False, + "message": message, + "errors": errors, + "status_code": status_code, + }, status=status_code) + + +def _get_status_code(exc): + exception_status_map = { + Http404: status.HTTP_404_NOT_FOUND, + DjangoPermissionDenied: status.HTTP_403_FORBIDDEN, + DRFPermissionDenied: status.HTTP_403_FORBIDDEN, + ObjectDoesNotExist: status.HTTP_404_NOT_FOUND, + MultipleObjectsReturned: status.HTTP_400_BAD_REQUEST, + ParseError: status.HTTP_400_BAD_REQUEST, + AuthenticationFailed: status.HTTP_401_UNAUTHORIZED, + NotAuthenticated: status.HTTP_401_UNAUTHORIZED, + NotFound: status.HTTP_404_NOT_FOUND, + MethodNotAllowed: status.HTTP_405_METHOD_NOT_ALLOWED, + NotAcceptable: status.HTTP_406_NOT_ACCEPTABLE, + UnsupportedMediaType: status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + Throttled: status.HTTP_429_TOO_MANY_REQUESTS, + ValueError: status.HTTP_400_BAD_REQUEST, + } + return exception_status_map.get(type(exc), status.HTTP_500_INTERNAL_SERVER_ERROR) + + +def _get_default_message(exc): + default_message_map = { + Http404: _('Page not found. The requested resource was not found.'), + (DjangoPermissionDenied, DRFPermissionDenied): _('You do not have permission to perform this action.'), + ObjectDoesNotExist: _('The requested resource does not exist.'), + MultipleObjectsReturned: _('Multiple records found. Please contact the administrator.'), + ParseError: _('The request data could not be parsed.'), + AuthenticationFailed: _('Please log in to access this resource.'), + NotAuthenticated: _('You did not provide the necessary credentials.'), + NotFound: _('This resource was not found.'), + MethodNotAllowed: _('This method is not allowed.'), + NotAcceptable: _('The request is not acceptable.'), + UnsupportedMediaType: _('Please provide a valid media type.'), + Throttled: _('Request was throttled. Expected available in {0} seconds.'), + } + return default_message_map.get(type(exc), _('An unexpected error occurred.')) + + +def _get_errors(exc, default_message): + if isinstance(exc, Throttled): + return [default_message.format(exc.wait)] + if isinstance(exc, ValueError): + return [str(exc)] + if isinstance(exc, Http404) or isinstance(exc, ObjectDoesNotExist): + return [default_message] + if isinstance(exc, Exception): + return [extract_error_from_html(exc)] + return [default_message] + + +def extract_error_from_html(exc): + detail = str(exc) + if '' in detail.lower(): + try: + soup = BeautifulSoup(detail, 'html.parser') + return soup.title.string.strip() if soup.title else _('An unexpected error occurred.') + except Exception: + return _('An unexpected error occurred and could not parse the error detail.') + return detail diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..3ba43db --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website_api.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/middlewares/translator.py b/middlewares/translator.py new file mode 100644 index 0000000..c210775 --- /dev/null +++ b/middlewares/translator.py @@ -0,0 +1,25 @@ +from django.utils import translation +from django.utils.deprecation import MiddlewareMixin + + +class APILanguageMiddleware(MiddlewareMixin): + """ + Middleware to set the language for the current request based on the 'Accept-Language' or custom header. + """ + + def process_request(self, request): + lang_code = request.headers.get('Accept-Language') + + if lang_code: + lang_code = lang_code.split(',')[0].strip() + + if lang_code and translation.check_for_language(lang_code): + translation.activate(lang_code) + request.LANGUAGE_CODE = lang_code + else: + translation.activate(translation.get_language()) + + def process_response(self, request, response): + response['Content-Language'] = translation.get_language() + translation.deactivate() + return response diff --git a/mixins/__init__.py b/mixins/__init__.py new file mode 100644 index 0000000..52f5859 --- /dev/null +++ b/mixins/__init__.py @@ -0,0 +1 @@ +from .api_response_mixin import APIResponseMixin \ No newline at end of file diff --git a/mixins/api_response_mixin.py b/mixins/api_response_mixin.py new file mode 100644 index 0000000..0c383a0 --- /dev/null +++ b/mixins/api_response_mixin.py @@ -0,0 +1,92 @@ +from typing import Any, Optional, Union, List + +from rest_framework import status +from rest_framework.pagination import PageNumberPagination +from rest_framework.request import Request +from rest_framework.response import Response + + +class APIResponseMixin: + """ + A mixin to standardize API responses, providing both success and error response methods, + including pagination support. + """ + + def success( + self, message: str, data: Optional[Any] = None, + status_code: int = status.HTTP_200_OK, + ) -> Response: + """ + Returns a standardized success response. + + :param message: A string message describing the success. + :param data: Optional data to be included in the response. + :param status_code: HTTP status code, default is 200 OK. + :return: DRF Response object with standardized success format. + """ + response_data = { + "status": True, + "message": message, + "data": data + } + return Response(response_data, status=status_code) + + def error( + self, message: str, errors: Optional[Union[str, List[str]]] = None, + status_code: int = status.HTTP_400_BAD_REQUEST, + ) -> Response: + """ + Returns a standardized error response. + + :param message: A string message describing the error. + :param errors: Optional error details can be a string or list of strings. + :param status_code: HTTP status code, default is 400 BAD REQUEST. + :return: DRF Response object with standardized error format. + """ + response_data = { + "status": False, + "message": message, + "errors": [errors] if isinstance(errors, str) else errors + } + return Response(response_data, status=status_code) + + def paginated_response( + self, request: Request, queryset: Any, serializer_class: Any, + message: str = "Success", page_size: int = 10, + status_code: int = status.HTTP_200_OK, + ) -> Response: + """ + Returns a paginated response with next and previous URLs. + + :param request: The DRF request object. + :param queryset: The queryset to paginate. + :param serializer_class: The serializer class to use for the data. + :param message: A string message describing the success. + :param page_size: Number of items per page. + :param status_code: HTTP status code, default is 200 OK. + :return: DRF Response object with standardized pagination format. + """ + paginator = PageNumberPagination() + paginator.page_size = page_size + + if not queryset.ordered: + queryset = queryset.order_by('created_at') + + paginated_queryset = paginator.paginate_queryset(queryset, request) + + data = serializer_class(paginated_queryset, many=True).data + + response_data = { + "status": True, + "message": message, + "data": data, + "status_code": status_code, + "pagination": { + "next": paginator.get_next_link(), + "previous": paginator.get_previous_link(), + "count": paginator.page.paginator.count, + "current_page": paginator.page.number, + "total_pages": paginator.page.paginator.num_pages + } + } + return Response(response_data, status=status.HTTP_200_OK) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55cff67 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,38 @@ +asgiref==3.7.2 +certifi==2023.11.17 +cffi==1.16.0 +cfgv==3.4.0 +charset-normalizer==3.3.2 +cryptography==41.0.7 +Deprecated==1.2.14 +distlib==0.3.8 +Django==5.0.1 +django-cors-headers==4.3.1 +django-dotenv==1.4.2 +django-filter==23.5 +django-oauth-toolkit==2.3.0 +djangorestframework==3.14.0 +drf-yasg==1.21.7 +filelock==3.13.1 +identify==2.5.33 +idna==3.6 +inflection==0.5.1 +jwcrypto==1.5.1 +nodeenv==1.8.0 +oauthlib==3.2.2 +packaging==23.2 +platformdirs==4.1.0 +psycopg2-binary==2.9.9 +# psycopg[binary] +pycparser==2.21 +python-dotenv==1.0.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +requests==2.31.0 +sqlparse==0.4.4 +typing_extensions==4.9.0 +tzdata==2023.4 +uritemplate==4.1.1 +urllib3==2.1.0 +virtualenv==20.25.0 +wrapt==1.16.0 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..3c02226 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +from .mail_service import MailService \ No newline at end of file diff --git a/services/mail_service.py b/services/mail_service.py new file mode 100644 index 0000000..887153c --- /dev/null +++ b/services/mail_service.py @@ -0,0 +1,45 @@ +from datetime import timedelta + +from django.conf import settings +from django.core.mail import EmailMessage +from django.template.loader import render_to_string +from django.utils.timezone import now + +from utils import generate_otp + + +class MailService: + def __init__(self, from_email=None): + self.mail = EmailMessage() + if from_email: + self.mail.from_email = from_email + else: + self.mail.from_email = settings.DEFAULT_FROM_EMAIL + + def send_mail(self, subject, message, to, attashment=None, context=None): + self.mail.subject = subject + if message.endswith('.html'): + self.mail.body = render_to_string(message, context=context) + self.mail.content_subtype = 'html' + else: + self.mail.body = message + self.mail.to = to + if attashment: + self.mail.attach(attashment.name, attashment.read(), attashment.content_type) + self.mail.send() + + def send_otp(self, reciever): + self.mail.subject = "OTP Code" + otp = generate_otp() + reciever.otp_codes.create(otp_code=otp, expires_at=now() + timedelta(minutes=10)) + self.mail.body = render_to_string("mails/otp.html", context={"otp": otp}) + self.mail.content_subtype = 'html' + self.mail.to = [reciever.email] + self.mail.send() + + def verify_otp(self, reciever, otp_code): + otp = reciever.otp_codes.filter(otp_code=otp_code).first() + if not otp or otp.has_expired(): + return False + otp.delete() + return True diff --git a/templates/docs/redoc.html b/templates/docs/redoc.html new file mode 100644 index 0000000..0179133 --- /dev/null +++ b/templates/docs/redoc.html @@ -0,0 +1,26 @@ + + + + + + Django Cameroon API + + + + + + + + diff --git a/templates/mails/otp.html b/templates/mails/otp.html new file mode 100644 index 0000000..7a25d88 --- /dev/null +++ b/templates/mails/otp.html @@ -0,0 +1,13 @@ + + + + + + OTP Code + + + Your requested verifcation code from django cameroon is {{ otp }}.
+ This Code will expire in 10 minutes.
+ If you did not request this code, please ignore this message.
+ + \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c313e3a --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +from .main import * \ No newline at end of file diff --git a/utils/auth.py b/utils/auth.py new file mode 100644 index 0000000..3ac81e4 --- /dev/null +++ b/utils/auth.py @@ -0,0 +1,33 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import BaseBackend +from django.db.models import Q + +User = get_user_model() + + +def authenticate_user(self, data): + try: + user = User.objects.get(Q(username=data['email_or_username']) | Q(email=data['email_or_username'])) + if user and user.check_password(data['password']): + return user + return None + except User.DoesNotExist: + raise ValueError("Authentication credentials invalid") + + +class EmailOrUsernameBackend(BaseBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + try: + user = User.objects.get(Q(username=username) | Q(email=username)) + except User.DoesNotExist: + return None + + if user.check_password(password): + return user + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/utils/main.py b/utils/main.py new file mode 100644 index 0000000..568846f --- /dev/null +++ b/utils/main.py @@ -0,0 +1,50 @@ +import os +import uuid + +documentation_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "documentation/") + + +def generate_uuid() -> str: + """ + Generate a unique identifier + + :return: A unique identifier + """ + return str(uuid.uuid4()) + + +def load_documentation(filename) -> str: + """ + Load documentation from a file + + :param filename: The name of the file to load + :return: The content of the file + """ + with open(os.path.join(documentation_path, filename), "r") as doc: + return doc.read() + + +import random + + +def generate_otp(): + otp = random.randint(100000, 999999) + return otp + + +def add_tag_groups(result, generator, request, public): + result['x-tagGroups'] = [ + { + 'name': 'User Management', + 'tags': ['Auth', 'User', ] + }, + { + 'name': 'Event Management', + 'tags': ['Reservations', 'Events', 'Speakers', "File Upload",] + }, + { + 'name': 'Blog Management', + 'tags': ['Blog', 'Posts', 'Comments', ] + } + ] + return result diff --git a/utils/uploads/__init__.py b/utils/uploads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/uploads/main.py b/utils/uploads/main.py new file mode 100644 index 0000000..e69de29 diff --git a/website_api/__init__.py b/website_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website_api/asgi.py b/website_api/asgi.py new file mode 100644 index 0000000..315c1b6 --- /dev/null +++ b/website_api/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for website_api project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website_api.settings") + +application = get_asgi_application() diff --git a/website_api/celery.py b/website_api/celery.py new file mode 100644 index 0000000..8e70731 --- /dev/null +++ b/website_api/celery.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals + +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'website_api.settings') + +app = Celery('website_api', broker=os.getenv("CELERY_BROKER_URL")) + +app.config_from_object('django.conf:settings', namespace='CELERY') + +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/website_api/routes/__init__.py b/website_api/routes/__init__.py new file mode 100644 index 0000000..15b6a64 --- /dev/null +++ b/website_api/routes/__init__.py @@ -0,0 +1 @@ +from .main import * diff --git a/website_api/routes/main.py b/website_api/routes/main.py new file mode 100644 index 0000000..ac06922 --- /dev/null +++ b/website_api/routes/main.py @@ -0,0 +1,25 @@ +import debug_toolbar +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include + +from .swagger import urlpatterns as swagger_urlpatterns + +BASE_API_URL = "api/v1" + + +urlpatterns = ( + [ path("admin/", admin.site.urls), + path('__debug__/', include(debug_toolbar.urls)), + path(f"{BASE_API_URL}/", include("apps.users.routes.api")), + path(f"{BASE_API_URL}/", include("apps.events.routes.api")), + path(f"{BASE_API_URL}/", include("apps.events.routes.extra")), + path(f"{BASE_API_URL}/", include("apps.blog.routes.api")), + ] + swagger_urlpatterns + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +) + +handler404 = "apps.users.views.index.page_not_found_view" +handler500 = "apps.users.views.index.server_error_view" diff --git a/website_api/routes/swagger.py b/website_api/routes/swagger.py new file mode 100644 index 0000000..0becea7 --- /dev/null +++ b/website_api/routes/swagger.py @@ -0,0 +1,26 @@ +from django.conf import settings +from django.urls import path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) +from django.views.generic import TemplateView +from apps.users.views.index import index + +if settings.ENVIRONMENT == "development": + urlpatterns = [ + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('', TemplateView.as_view(template_name='docs/redoc.html'), name='index'), + path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + # re_path(r'^$', TemplateView.as_view(template_name='docs/redoc.html'), name='index'), + # re_path(r'$swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + ] +elif settings.ENVIRONMENT == "production": + urlpatterns = [ + path('', index, name='index'), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('redoc/', TemplateView.as_view(template_name='docs/redoc.html'), name='index'), + ] diff --git a/website_api/settings/__init__.py b/website_api/settings/__init__.py new file mode 100644 index 0000000..ab1c4b9 --- /dev/null +++ b/website_api/settings/__init__.py @@ -0,0 +1,3 @@ +from .apps import * +from .base import * +from .extra import * diff --git a/website_api/settings/apps.py b/website_api/settings/apps.py new file mode 100644 index 0000000..ac2bb77 --- /dev/null +++ b/website_api/settings/apps.py @@ -0,0 +1,50 @@ +THIRD_PARTY_APPS = [ + "corsheaders", + "rest_framework", + "drf_spectacular", + "drf_spectacular_sidecar", + "oauth2_provider", + "django_extensions", +] + +CUSTOM_APPS = [ + "apps.users", + "apps.events", + "apps.blog", +] + +# ---------------------- some extra stuff ------------------------------------- # + +EXTRA_MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "crequest.middleware.CrequestMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", +] + + + +# CORS configuration +CORS_ALLOW_ALL_ORIGINS = True + +CORS_ALLOW_CREDENTIALS = True + +CORS_ALLOWED_ORIGIN_REGEXES = [ + r"^http://localhost$", + r"^https://\w+\.djangocameroon\.site", + r"^https://\w+\.djangocameroon\.site:$", + r"^https://\w+\.djangocameroon\.site:\d+$", + r"^https://\w+\.djangocameroon\.site:\d+/$", +] + +CORS_ALLOWED_ORIGINS = [ + "http://localhost", + "https://www.djangocameroon.site", + "https://djangocameroon.site", +] + +CORS_ORIGIN_WHITELIST = [ + "http://localhost", + "https://djangocameroon.site", + "https://beta.djangocameroon.site", + "https://www.djangocameroon.site", +] diff --git a/website_api/settings/base.py b/website_api/settings/base.py new file mode 100644 index 0000000..7f2c562 --- /dev/null +++ b/website_api/settings/base.py @@ -0,0 +1,149 @@ +import os + +from dotenv import load_dotenv + +from .apps import CUSTOM_APPS, THIRD_PARTY_APPS, EXTRA_MIDDLEWARE + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +load_dotenv(os.path.join(BASE_DIR, ".env")) + +SECRET_KEY = os.getenv("SECRET_KEY") +ENVIRONMENT = os.getenv("ENVIRONMENT") +DEBUG = True if os.getenv('DEBUG') == 'True' else False + +ALLOWED_HOSTS = (os.environ.get("ALLOWED_HOSTS", "*")).split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + 'rest_framework.authtoken', + +] + +INSTALLED_APPS += THIRD_PARTY_APPS +INSTALLED_APPS += CUSTOM_APPS + +BASE_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", +] + +MIDDLEWARE = EXTRA_MIDDLEWARE + BASE_MIDDLEWARE + +ROOT_URLCONF = "website_api.routes" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.abspath(os.path.join(BASE_DIR, "templates")), + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "website_api.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + } +} + +# Email settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_PORT = os.getenv("EMAIL_PORT") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", False) +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +LANGUAGES = [ + ('en', 'English'), + ('fr', 'French'), + ('pcm', 'Pidgin'), +] +LOCALE_PATHS = [ + os.path.abspath(os.path.join(BASE_DIR, "locale")), +] + +TIME_ZONE = "Africa/Douala" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") + +# Media files (Images, Videos, etc) +MEDIA_URL = "media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# Custom user model +AUTH_USER_MODEL = "users.User" + + +CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' + diff --git a/website_api/settings/blog.py b/website_api/settings/blog.py new file mode 100644 index 0000000..e69de29 diff --git a/website_api/settings/celery.py b/website_api/settings/celery.py new file mode 100644 index 0000000..b39095f --- /dev/null +++ b/website_api/settings/celery.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import, unicode_literals +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'website_api.settings') + +app = Celery('website_api') + +app.config_from_object('django.conf:settings', namespace='CELERY') + +app.autodiscover_tasks() diff --git a/website_api/settings/extra.py b/website_api/settings/extra.py new file mode 100644 index 0000000..8de894e --- /dev/null +++ b/website_api/settings/extra.py @@ -0,0 +1,119 @@ +import os + +from utils.main import load_documentation +from .base import BASE_DIR, TIME_ZONE, INSTALLED_APPS, MIDDLEWARE + +REST_FRAMEWORK = { + "EXCEPTION_HANDLER": "exceptions.rest_exception.rest_exception_handler", + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "oauth2_provider.contrib.rest_framework.OAuth2Authentication", + ), + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_PAGINATION_CLASS": "apps.users.pagination.CustomPagination", + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + "PAGE_SIZE": 100, + "NON_FIELD_ERRORS_KEY": "message", +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Django Cameroon API', + 'DESCRIPTION': load_documentation('main.md'), + 'EXTENSIONS_INFO': { + 'x-logo': { + 'url': 'https://avatars.githubusercontent.com/u/142497557', + 'backgroundColor': '#FFFFFF', + 'altText': 'Django Cameroon', + 'href': 'https://djangocameroon.site', + 'style': 'margin: 0 auto; display: block; border-radius: 50%; border: 1px solid #000000;', + } + }, + 'CONTACT': { + 'name': 'Django Cameroon', + 'url': 'https://djangocameroon.site', + 'email': 'support@djangocameroon.site', + }, + 'REDOC_SETTINGS': { + 'favicon': 'https://avatars.githubusercontent.com/u/142497557', + }, + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], + 'POSTPROCESSING_HOOKS': [ + 'utils.main.add_tag_groups' + ], +} + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'utils.auth.EmailOrUsernameBackend', +] + +# Django extensions +SHELL_PLUS = "ipython" +SHELL_PLUS_PRINT_SQL = True +RUNSERVER_PLUS_POLLER_RELOADER_INTERVAL = 1 + +# https redirect +if os.getenv("ENVIRONMENT") == "production": + META_SITE_NAME = "Django Cameroon" + META_USE_OG_PROPERTIES = True + META_USE_TWITTER_PROPERTIES = True + META_USE_TITLE_TAG = True + META_USE_SITES = True + META_SITE_PROTOCOL = "https" + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_SSL_REDIRECT = True + USE_X_FORWARDED_HOST = True + USE_X_FORWARDED_PORT = True + +# AWS S3 settings +if os.getenv("ENVIRONMENT") == "production": + AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') + AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') + AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' + AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', + } + AWS_LOCATION = 'static' + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/' + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/static/' + STATIC_ROOT = os.path.join(BASE_DIR, 'static') + STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' +else: + WHITENOISE_USE_FINDERS = True + WHITENOISE_AUTOREFRESH = False + WHITENOISE_MAX_AGE = 31536000 + + MEDIA_URL = '/media/' + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + STATIC_URL = '/static/' + STATIC_ROOT = os.path.join(BASE_DIR, 'static') + STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Celery settings +CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL') +CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND') +CELERY_TIMEZONE = TIME_ZONE +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Django Debug ToolBar settings +if os.getenv("ENVIRONMENT") == "development": + INSTALLED_APPS += ['debug_toolbar'] + MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] + DEBUG_TOOLBAR_CONFIG = { + 'SHOW_TOOLBAR_CALLBACK': lambda request: True, + } + INTERNAL_IPS = [ + '127.0.0.1', + ] diff --git a/website_api/wsgi.py b/website_api/wsgi.py new file mode 100644 index 0000000..8961c2e --- /dev/null +++ b/website_api/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for website_api project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website_api.settings") + +application = get_wsgi_application() From 0f40fb79bfe6b57ae7aa429907c04c606badb947 Mon Sep 17 00:00:00 2001 From: la-machine Date: Thu, 11 Dec 2025 23:51:39 +0100 Subject: [PATCH 2/3] Bilingual Readme doc file --- README.En.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 141 insertions(+) create mode 100644 README.En.md diff --git a/README.En.md b/README.En.md new file mode 100644 index 0000000..c37db6f --- /dev/null +++ b/README.En.md @@ -0,0 +1,139 @@ +🌍 Available languages: **English** | [Français](README.fr.md) + +# Website API + +A RESTful API built with Django and Django REST Framework to manage website content, including blog features, events, and user management. + +## 📋 Prerequisites + +- Python 3.8+ +- PostgreSQL 12+ +- Redis (for caching and queues) +- pip (Python package manager) + +## 🚀 Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/charles-kamga/website_api.git + cd website_api + ``` + +2. **Set up the virtual environment** + ```bash + python -m venv venv + source venv/bin/activate # On Windows: .\venv\Scripts\activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Configure the PostgreSQL database** + ```sql + -- Connect to PostgreSQL + sudo -u postgres psql + + -- Create the database + CREATE DATABASE django_website_db; + + -- Create a user (replace values in brackets) + CREATE USER [db_user] WITH PASSWORD '[your_password]'; + + -- Grant privileges + GRANT ALL PRIVILEGES ON DATABASE django_website_db TO [db_user]; + ``` + +5. **Configure environment variables** + ```bash + cp .env.example .env + ``` + + Edit the `.env` file with your settings: + - `DB_*`: Database connection parameters + - `SECRET_KEY`: Django secret key (generate a new one for production) + - `EMAIL_*`: SMTP configuration for emails + - `TWILLIO_*`: Twilio credentials for SMS verification (optional) + +6. **Apply migrations** + ```bash + python manage.py migrate + ``` + +7. **Create a superuser (optional)** + ```bash + python manage.py createsuperuser + ``` + +8. **Run the development server** + ```bash + python manage.py runserver + ``` + + The API will be available at: http://127.0.0.1:8000/ + The admin interface will be available at: http://127.0.0.1:8000/admin/ + +## 🏗 Project Structure + +``` +website_api/ +├── apps/ # Django applications +│ ├── blog/ # Blog article management +│ ├── events/ # Events management +│ └── users/ # User management and authentication +├── config/ # Project configuration +├── documentation/ # Additional documentation +├── middlewares/ # Custom middlewares +├── services/ # Business logic +└── website_api/ # Main project settings +``` + +## 🔧 Environment Variables + +| Variable | Description | Default Value | +|----------|-------------|----------------| +| `DEBUG` | Debug mode | `True` in development, `False` in production | +| `SECRET_KEY` | Django secret key | Must be defined in production | +| `DB_*` | Database settings | See `.env.example` | +| `EMAIL_*` | SMTP configuration | Must be set for emails | +| `REDIS_URL` | Redis connection URL | `redis://127.0.0.1:6379` | +| `TWILLIO_*` | Twilio credentials (SMS) | Optional | + +## 📚 API Documentation + +The API documentation is available at `/api/docs/` when the server is running. + +## 🧪 Running Tests + +```bash +# Run all tests +python manage.py test + +# Run tests for a specific app +python manage.py test apps.users +``` + +## 🛠 Development Tools + +- **Linting**: `flake8` +- **Formatting**: `black` +- **Import sorting**: `isort` + +## 🤝 Contributing + +Contributions are welcome! Here’s how to contribute: + +1. Fork the project +2. Create a feature branch (`git checkout -b feature/my-new-feature`) +3. Commit your changes (`git commit -am 'Add new feature'`) +4. Push the branch (`git push origin feature/my-new-feature`) +5. Create a Pull Request + +## 📄 License + +This project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details. + +## 📧 Contact + +For any questions, please open an issue on GitHub or contact the development team. diff --git a/README.md b/README.md index 4eb0eed..479f8e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +🌍 Langues disponibles : [English](README.En.md) | **Français** + # Website API Une API RESTful construite avec Django et Django REST Framework pour gérer le contenu d'un site web, incluant des fonctionnalités de blog, d'événements et de gestion d'utilisateurs. From d8b63681d6b47b347fdfaf1c25d0acf316760a8c Mon Sep 17 00:00:00 2001 From: la-machine Date: Fri, 12 Dec 2025 00:08:46 +0100 Subject: [PATCH 3/3] feature/Bilingual Readme doc file --- README.En.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.En.md b/README.En.md index c37db6f..075274e 100644 --- a/README.En.md +++ b/README.En.md @@ -1,4 +1,4 @@ -🌍 Available languages: **English** | [Français](README.fr.md) +🌍 Available languages: **English** | [Français](README.md) # Website API