From 22e2774c6937436f16180ae1ff01bf9742d93b12 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 15:11:44 +0300 Subject: [PATCH 01/34] feat: initialize Django project and create notes app --- manage.py | 22 ++++++ notes/__init__.py | 0 notes/admin.py | 3 + notes/apps.py | 6 ++ notes/migrations/__init__.py | 0 notes/models.py | 3 + notes/tests.py | 3 + notes/views.py | 3 + requirements.txt | 5 ++ secret_notes_project/__init__.py | 0 secret_notes_project/asgi.py | 16 ++++ secret_notes_project/settings.py | 124 +++++++++++++++++++++++++++++++ secret_notes_project/urls.py | 22 ++++++ secret_notes_project/wsgi.py | 16 ++++ 14 files changed, 223 insertions(+) create mode 100755 manage.py create mode 100644 notes/__init__.py create mode 100644 notes/admin.py create mode 100644 notes/apps.py create mode 100644 notes/migrations/__init__.py create mode 100644 notes/models.py create mode 100644 notes/tests.py create mode 100644 notes/views.py create mode 100644 requirements.txt create mode 100644 secret_notes_project/__init__.py create mode 100644 secret_notes_project/asgi.py create mode 100644 secret_notes_project/settings.py create mode 100644 secret_notes_project/urls.py create mode 100644 secret_notes_project/wsgi.py diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..16067f1 --- /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', 'secret_notes_project.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/notes/__init__.py b/notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notes/admin.py b/notes/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/notes/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/notes/apps.py b/notes/apps.py new file mode 100644 index 0000000..832dd3f --- /dev/null +++ b/notes/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notes' diff --git a/notes/migrations/__init__.py b/notes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notes/models.py b/notes/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/notes/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/notes/tests.py b/notes/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notes/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notes/views.py b/notes/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/notes/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..89aa940 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +asgiref==3.8.1 +Django==5.1.1 +psycopg2-binary==2.9.9 +sqlparse==0.5.1 +typing_extensions==4.12.2 diff --git a/secret_notes_project/__init__.py b/secret_notes_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secret_notes_project/asgi.py b/secret_notes_project/asgi.py new file mode 100644 index 0000000..acd084e --- /dev/null +++ b/secret_notes_project/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for secret_notes_project 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.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secret_notes_project.settings') + +application = get_asgi_application() diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py new file mode 100644 index 0000000..7202496 --- /dev/null +++ b/secret_notes_project/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for secret_notes_project project. + +Generated by 'django-admin startproject' using Django 5.1.1. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-gp1gt41n0po!a6$8!v3a)w=#053opjfcuj=0wv12jcbk$x*q2y" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "notes", +] + +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", +] + +ROOT_URLCONF = "secret_notes_project.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "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 = "secret_notes_project.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/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.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/secret_notes_project/urls.py b/secret_notes_project/urls.py new file mode 100644 index 0000000..8a2e22d --- /dev/null +++ b/secret_notes_project/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for secret_notes_project project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/secret_notes_project/wsgi.py b/secret_notes_project/wsgi.py new file mode 100644 index 0000000..c542f5f --- /dev/null +++ b/secret_notes_project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for secret_notes_project 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.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secret_notes_project.settings') + +application = get_wsgi_application() From 8a038e94eccd26956e0fe2264eb1d3716c410201 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 15:31:41 +0300 Subject: [PATCH 02/34] chore: update .gitignore to include .vscode and switch database to PostgreSQL --- .gitignore | 1 + secret_notes_project/settings.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 82f9275..380b609 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.vscode # Spyder project settings .spyderproject diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py index 7202496..e8cf3a6 100644 --- a/secret_notes_project/settings.py +++ b/secret_notes_project/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -76,8 +77,12 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("DB_NAME", "secret_notes_db"), + "USER": os.environ.get("DB_USER", "postgres"), + "PASSWORD": os.environ.get("DB_PASSWORD", ""), + "HOST": os.environ.get("DB_HOST", "db"), + "PORT": os.environ.get("DB_PORT", "5432"), } } From 6006acb526a307aedc04186702bd03f054f681d3 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 19:01:48 +0300 Subject: [PATCH 03/34] feat: add SecretNote model with view tracking and expiration logic --- notes/models.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/notes/models.py b/notes/models.py index 71a8362..50b1af1 100644 --- a/notes/models.py +++ b/notes/models.py @@ -1,3 +1,29 @@ +import uuid + from django.db import models +from django.utils import timezone + + +class SecretNote(models.Model): + content = models.TextField() + url_key = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + expiration_time = models.DateTimeField(null=True, blank=True) + max_views = models.IntegerField(default=1) + views = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + def is_expired(self): + if self.expiration_time and timezone.now() > self.expiration_time: + return True + + if self.views > self.max_views: + return True + + return False + + def increment_view(self): + self.views += 1 + self.save() -# Create your models here. + def __str__(self): + return f"SecretNote {self.url_key}" From af6ecd89b5cd93587b9118678a25fc1f7a891282 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 19:49:42 +0300 Subject: [PATCH 04/34] feat: add SecretNoteForm to handle note creation with optional expiration --- notes/form.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 notes/form.py diff --git a/notes/form.py b/notes/form.py new file mode 100644 index 0000000..e050d02 --- /dev/null +++ b/notes/form.py @@ -0,0 +1,17 @@ +from django import forms + +from .models import SecretNote + + +class SecretNoteForm(forms.ModelForm): + expiration_hours = forms.IntegerField( + min_value=1, + max_value=72, + required=False, + help_text="Number of hours before the note expires", + ) + + class Meta: + model = SecretNote + fields = ["content", "max_views"] + widgets = {"content": forms.Textarea(attrs={"rows": 4, "cols": 50})} From e33e5819d7dbd02bc0c93ed200cc04b5057e0935 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 20:58:03 +0300 Subject: [PATCH 05/34] feat: add create_note.html template for creating secret notes --- notes/templates/create_note.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 notes/templates/create_note.html diff --git a/notes/templates/create_note.html b/notes/templates/create_note.html new file mode 100644 index 0000000..60769bf --- /dev/null +++ b/notes/templates/create_note.html @@ -0,0 +1,15 @@ + + + + + + Create Secret Note + + +

Create a Secret Note

+
+ {{ form.as_p }} + +
+ + From 0480c5f036c668fcec5d9961b646705c7ab65f36 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 21:04:56 +0300 Subject: [PATCH 06/34] feat: add note_created.html template for displaying the created note link --- notes/templates/note_created.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 notes/templates/note_created.html diff --git a/notes/templates/note_created.html b/notes/templates/note_created.html new file mode 100644 index 0000000..12ffe00 --- /dev/null +++ b/notes/templates/note_created.html @@ -0,0 +1,17 @@ + + + + + + Note Created + + +

Your Secret Note has been created

+

Share this link to view the note:

+

+ {% url 'view_note' note.url_key %} +

+ + From 9efc0fe6988b087562ca544c3450694602abcdfc Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 21:42:22 +0300 Subject: [PATCH 07/34] feat: add note_view.html template to display secret notes --- notes/templates/note_view.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 notes/templates/note_view.html diff --git a/notes/templates/note_view.html b/notes/templates/note_view.html new file mode 100644 index 0000000..958e1b8 --- /dev/null +++ b/notes/templates/note_view.html @@ -0,0 +1,15 @@ + + + + + + View Secret Note + + +

Secret Note

+

{{ content }}

+ {% if deleted %} +

This note has now been deleted

+ {% endif %} + + From 83ac04313f5ec17845cd971d07e01e0fdb043cdc Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Mon, 7 Oct 2024 21:44:46 +0300 Subject: [PATCH 08/34] feat: add note_expired.html template for expired or already viewed notes --- notes/templates/note_expired.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 notes/templates/note_expired.html diff --git a/notes/templates/note_expired.html b/notes/templates/note_expired.html new file mode 100644 index 0000000..3a462cf --- /dev/null +++ b/notes/templates/note_expired.html @@ -0,0 +1,12 @@ + + + + + + Note Expired + + +

Note Expired

+

This note has expired or has already been viewed.

+ + From 8cb53d90d2e5f7a23165ec71902e1a81a19ed568 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 12:04:12 +0300 Subject: [PATCH 09/34] feat: implement create_note view to handle note creation with optional expiration --- notes/views.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/notes/views.py b/notes/views.py index 91ea44a..78df50b 100644 --- a/notes/views.py +++ b/notes/views.py @@ -1,3 +1,24 @@ from django.shortcuts import render +from django.utils import timezone -# Create your views here. +from .form import SecretNoteForm +from .models import SecretNote + + +def create_note(request): + if request.method == "POST": + form = SecretNoteForm(request.POST) + if form.is_valid(): + note = form.save(commit=False) + + if form.cleaned_data["expiration_hours"]: + note.expiration_time = timezone.now() + timezone.timedelta( + hours=form.cleaned_data["expiration_hours"] + ) + note.save() + + return render(request, "note_created.html", {"note": note}) + + else: + form = SecretNoteForm() + return render(request, "create_note.html", {"form": form}) From 49e25f8f6612f533b4f5d7ee2c9e6f1191414e80 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 13:26:23 +0300 Subject: [PATCH 10/34] feat: add view_note view to handle note viewing and expiration checks --- notes/views.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/notes/views.py b/notes/views.py index 78df50b..bdb2898 100644 --- a/notes/views.py +++ b/notes/views.py @@ -1,4 +1,4 @@ -from django.shortcuts import render +from django.shortcuts import get_object_or_404, render from django.utils import timezone from .form import SecretNoteForm @@ -22,3 +22,18 @@ def create_note(request): else: form = SecretNoteForm() return render(request, "create_note.html", {"form": form}) + + +def view_note(request, url_key): + note = get_object_or_404(SecretNote, url_key=url_key) + if note.is_expired(): + note.delete() + return render(request, "note_expired.html") + + note.increment_view() + if note.is_expired(): + content = note.content + note.delete() + return render(request, "note_view.html", {"content": content, "deleted": True}) + + return render(request, "note_view.html", {"content": note.content, "deleted": False}) From cc3cc81e08275d01e22832a1a88720af342c2872 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 13:40:34 +0300 Subject: [PATCH 11/34] feat: include notes app URLs in the main project urls.py --- notes/urls.py | 8 ++++++++ secret_notes_project/urls.py | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 notes/urls.py diff --git a/notes/urls.py b/notes/urls.py new file mode 100644 index 0000000..72cfe95 --- /dev/null +++ b/notes/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("create/", views.create_note, name="create_note"), + path("note//", views.view_note, name="view_note"), +] diff --git a/secret_notes_project/urls.py b/secret_notes_project/urls.py index 8a2e22d..c9abe9c 100644 --- a/secret_notes_project/urls.py +++ b/secret_notes_project/urls.py @@ -14,9 +14,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), + path("", include("notes.urls")) ] From 61b585af7ae969f918593816ff789c77f8286dc9 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 18:57:04 +0300 Subject: [PATCH 12/34] feat: add rate limit error template --- notes/templates/ratelimit_error.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 notes/templates/ratelimit_error.html diff --git a/notes/templates/ratelimit_error.html b/notes/templates/ratelimit_error.html new file mode 100644 index 0000000..ce37f1e --- /dev/null +++ b/notes/templates/ratelimit_error.html @@ -0,0 +1,12 @@ + + + + + + Rate Limit Exceeded + + +

Rate Limit Exceeded

+

You have exceeded the rate limit. Please try again later.

+ + From 2af02fb8ccf287b3a317d62865e174d8ceb972ec Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 19:10:23 +0300 Subject: [PATCH 13/34] feat: add rate limiting to create and view note endpoints --- notes/views.py | 11 ++++++++++- secret_notes_project/settings.py | 1 + secret_notes_project/urls.py | 9 +++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/notes/views.py b/notes/views.py index bdb2898..f85f1e1 100644 --- a/notes/views.py +++ b/notes/views.py @@ -1,10 +1,12 @@ from django.shortcuts import get_object_or_404, render from django.utils import timezone +from django_ratelimit.decorators import ratelimit from .form import SecretNoteForm from .models import SecretNote +@ratelimit(key="ip", rate="5/m", method=["GET", "POST"], block=True) def create_note(request): if request.method == "POST": form = SecretNoteForm(request.POST) @@ -24,6 +26,7 @@ def create_note(request): return render(request, "create_note.html", {"form": form}) +@ratelimit(key="ip", rate="10/m", method=["GET"], block=True) def view_note(request, url_key): note = get_object_or_404(SecretNote, url_key=url_key) if note.is_expired(): @@ -36,4 +39,10 @@ def view_note(request, url_key): note.delete() return render(request, "note_view.html", {"content": content, "deleted": True}) - return render(request, "note_view.html", {"content": note.content, "deleted": False}) + return render( + request, "note_view.html", {"content": note.content, "deleted": False} + ) + + +def ratelimit_error(request): + return render(request, "ratelimit_error.html", status=429) diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py index e8cf3a6..fb85c2f 100644 --- a/secret_notes_project/settings.py +++ b/secret_notes_project/settings.py @@ -39,6 +39,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "notes", + "ratelimit", ] MIDDLEWARE = [ diff --git a/secret_notes_project/urls.py b/secret_notes_project/urls.py index c9abe9c..b8b40e1 100644 --- a/secret_notes_project/urls.py +++ b/secret_notes_project/urls.py @@ -18,7 +18,8 @@ from django.contrib import admin from django.urls import include, path -urlpatterns = [ - path("admin/", admin.site.urls), - path("", include("notes.urls")) -] +from notes.views import ratelimit_error + +urlpatterns = [path("admin/", admin.site.urls), path("", include("notes.urls"))] + +handler429 = ratelimit_error From e3de391d2d720a5be9cd6de984d07d10c4393ef3 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 22:39:08 +0300 Subject: [PATCH 14/34] test: add unit tests for SecretNote model methods --- notes/tests.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/notes/tests.py b/notes/tests.py index 7ce503c..efe7368 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -1,3 +1,25 @@ from django.test import TestCase +from django.utils import timezone -# Create your tests here. +from .form import SecretNoteForm +from .models import SecretNote + + +class SecretNoteModelTest(TestCase): + def test_is_expired(self): + expired_note = SecretNote.objects.create( + content="Expired note", + expiration_time=timezone.now() - timezone.timedelta(hours=1), + ) + self.assertTrue(expired_note.is_expired()) + + max_views_note = SecretNote.objects.create( + content="Max views note", max_views=1, views=1 + ) + self.assertTrue(max_views_note.is_expired()) + + valid_note = SecretNote.objects.create( + content="Valid note", + expiration_time=timezone.now + timezone.timedelta(hours=1), + ) + self.assertFalse(valid_note.is_expired()) From ee7ebc172b68551e31ea5dd84244c17ca1987b9c Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 8 Oct 2024 22:59:51 +0300 Subject: [PATCH 15/34] test: add unit tests for SecretNoteForm validation --- notes/tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/notes/tests.py b/notes/tests.py index efe7368..1071ae4 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -23,3 +23,16 @@ def test_is_expired(self): expiration_time=timezone.now + timezone.timedelta(hours=1), ) self.assertFalse(valid_note.is_expired()) + + +class SecretNoteFormTest(TestCase): + def test_valid_form(self): + form_data = {"content": "Test note", "max_views": 5, "expiration_hours": 24} + form = SecretNoteForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_form(self): + form_data = {"content": "", "max_views": 0, "expiration_hours": 100} + form = SecretNoteForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 3) From f5e06ce8be6b1b5784ea755135cbb0a9c501a2b3 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 11:51:51 +0300 Subject: [PATCH 16/34] fix: correct is_expired method logic and test_invalid_form --- notes/migrations/0001_initial.py | 27 +++++++++++++++++++++++++++ notes/models.py | 2 +- notes/tests.py | 7 ++++--- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 notes/migrations/0001_initial.py diff --git a/notes/migrations/0001_initial.py b/notes/migrations/0001_initial.py new file mode 100644 index 0000000..2b4e358 --- /dev/null +++ b/notes/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.1 on 2024-10-09 08:38 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SecretNote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField()), + ('url_key', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('expiration_time', models.DateTimeField(blank=True, null=True)), + ('max_views', models.IntegerField(default=1)), + ('views', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/notes/models.py b/notes/models.py index 50b1af1..3e3a637 100644 --- a/notes/models.py +++ b/notes/models.py @@ -16,7 +16,7 @@ def is_expired(self): if self.expiration_time and timezone.now() > self.expiration_time: return True - if self.views > self.max_views: + if self.views >= self.max_views: return True return False diff --git a/notes/tests.py b/notes/tests.py index 1071ae4..dc43653 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -1,4 +1,5 @@ -from django.test import TestCase +from django.test import Client, TestCase +from django.urls import reverse from django.utils import timezone from .form import SecretNoteForm @@ -20,7 +21,7 @@ def test_is_expired(self): valid_note = SecretNote.objects.create( content="Valid note", - expiration_time=timezone.now + timezone.timedelta(hours=1), + expiration_time=timezone.now() + timezone.timedelta(hours=1), ) self.assertFalse(valid_note.is_expired()) @@ -35,4 +36,4 @@ def test_invalid_form(self): form_data = {"content": "", "max_views": 0, "expiration_hours": 100} form = SecretNoteForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 3) + self.assertEqual(len(form.errors), 2) From 949cdf6236daa9700e890f9fba1ce99d6659045a Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 11:57:35 +0300 Subject: [PATCH 17/34] test: add view test for note creation functionality --- notes/tests.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/notes/tests.py b/notes/tests.py index dc43653..dfd4b42 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -37,3 +37,17 @@ def test_invalid_form(self): form = SecretNoteForm(data=form_data) self.assertFalse(form.is_valid()) self.assertEqual(len(form.errors), 2) + + +class NoteCreationViewTest(TestCase): + def setUP(self): + self.client = Client() + + def test_create_note(self): + response = self.client.post( + reverse("create_note"), + {"content": "Test note content", "max_views": 3, "expiration_hours": 24}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Your Secret Note has been created") + self.assertEqual(SecretNote.objects.count(), 1) From 2e4fbfc38988af1bf13f9776a2b8630abe69461d Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 12:38:40 +0300 Subject: [PATCH 18/34] test: add view test for note viewing --- notes/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/notes/tests.py b/notes/tests.py index dfd4b42..7704fe6 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -51,3 +51,19 @@ def test_create_note(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Your Secret Note has been created") self.assertEqual(SecretNote.objects.count(), 1) + + +class NoteViewTest(TestCase): + def setUp(self): + self.client = Client() + self.note = SecretNote.objects.create(content="Test note content", max_views=2) + + def test_view_note(self): + response = self.client.get(reverse("view_note", args=[self.note.url_key])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test note content") + + response = self.client.get(reverse("view_note", args=[self.note.url_key])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This note has now been deleted") + self.assertEqual(SecretNote.objects.count(), 0) From a89aabe80d0044aa715f2d0bda4109c869fd9d29 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 14:19:48 +0300 Subject: [PATCH 19/34] fix: add CSRF token to the create note form --- notes/templates/create_note.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notes/templates/create_note.html b/notes/templates/create_note.html index 60769bf..a2f8764 100644 --- a/notes/templates/create_note.html +++ b/notes/templates/create_note.html @@ -8,7 +8,7 @@

Create a Secret Note

- {{ form.as_p }} + {% csrf_token %} {{ form.as_p }}
From 452583aa1ab47d794362f96af813a462a9b63d67 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 15:41:46 +0300 Subject: [PATCH 20/34] feat: implement custom rate limit error handling middleware --- notes/middleware.py | 15 +++++++++++++++ notes/views.py | 6 +++--- secret_notes_project/urls.py | 1 + 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 notes/middleware.py diff --git a/notes/middleware.py b/notes/middleware.py new file mode 100644 index 0000000..ebc5de4 --- /dev/null +++ b/notes/middleware.py @@ -0,0 +1,15 @@ +from django.http import HttpResponseForbidden +from django.shortcuts import render +from django_ratelimit.exceptions import Ratelimited + + +class RatelimitMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + def process_exception(self, request, exception): + if isinstance(exception, Ratelimited): + return render(request, "ratelimit_error.html", status=429) diff --git a/notes/views.py b/notes/views.py index f85f1e1..817a337 100644 --- a/notes/views.py +++ b/notes/views.py @@ -6,7 +6,7 @@ from .models import SecretNote -@ratelimit(key="ip", rate="5/m", method=["GET", "POST"], block=True) +@ratelimit(key="ip", rate="5/m", method=["GET", "POST"]) def create_note(request): if request.method == "POST": form = SecretNoteForm(request.POST) @@ -26,7 +26,7 @@ def create_note(request): return render(request, "create_note.html", {"form": form}) -@ratelimit(key="ip", rate="10/m", method=["GET"], block=True) +@ratelimit(key="ip", rate="10/m", method=["GET"]) def view_note(request, url_key): note = get_object_or_404(SecretNote, url_key=url_key) if note.is_expired(): @@ -44,5 +44,5 @@ def view_note(request, url_key): ) -def ratelimit_error(request): +def ratelimit_error(request, exception=None): return render(request, "ratelimit_error.html", status=429) diff --git a/secret_notes_project/urls.py b/secret_notes_project/urls.py index b8b40e1..f9332a3 100644 --- a/secret_notes_project/urls.py +++ b/secret_notes_project/urls.py @@ -23,3 +23,4 @@ urlpatterns = [path("admin/", admin.site.urls), path("", include("notes.urls"))] handler429 = ratelimit_error +handler403 = ratelimit_error From 13ebdc8e374f055d3cf9160afe03f41617ef317d Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 20:40:20 +0300 Subject: [PATCH 21/34] feat: add styling and index page and adjusting templates --- notes/form.py | 3 +- notes/static/styles.css | 110 +++++++++++++++++++++++++++ notes/templates/create_note.html | 28 +++++-- notes/templates/index.html | 40 ++++++++++ notes/templates/note_created.html | 5 ++ notes/templates/note_expired.html | 3 + notes/templates/note_view.html | 5 +- notes/templates/ratelimit_error.html | 3 + notes/urls.py | 1 + notes/views.py | 19 ++++- 10 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 notes/static/styles.css create mode 100644 notes/templates/index.html diff --git a/notes/form.py b/notes/form.py index e050d02..15e313e 100644 --- a/notes/form.py +++ b/notes/form.py @@ -8,10 +8,9 @@ class SecretNoteForm(forms.ModelForm): min_value=1, max_value=72, required=False, - help_text="Number of hours before the note expires", ) class Meta: model = SecretNote fields = ["content", "max_views"] - widgets = {"content": forms.Textarea(attrs={"rows": 4, "cols": 50})} + widgets = {"content": forms.Textarea()} diff --git a/notes/static/styles.css b/notes/static/styles.css new file mode 100644 index 0000000..fb3eff6 --- /dev/null +++ b/notes/static/styles.css @@ -0,0 +1,110 @@ +body { + font-family: "Roboto", Arial, sans-serif; + background-color: #f9fafc; + color: #333; + margin: 0; + padding: 0; +} + +.container { + max-width: 800px; + margin: 50px auto; + padding: 20px; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.form-container { + max-width: 400px; + margin: 100px auto; + padding: 30px; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +h1, +h2 { + text-align: center; + color: #2c3e50; +} + +form { + display: flex; + flex-direction: column; + justify-content: center; + gap: 15px; + padding: 20px; +} + +input[type="text"], +textarea { + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + width: 100%; + box-sizing: border-box; + font-size: 16px; +} + +input[type="text"]:focus, +textarea:focus { + outline: none; + border-color: #5cb85c; +} + +button { + padding: 12px; + background-color: #5cb85c; + border: none; + border-radius: 6px; + color: #ffffff; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +button:hover { + background-color: #4cae4c; +} + +a { + color: #3498db; + text-decoration: none; + transition: color 0.3s; +} + +a:hover { + color: #2980b9; +} + +p { + text-align: center; + font-size: 18px; +} + +.card { + padding: 20px; + margin: 20px 0; + background: #fdfdfd; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.card a { + font-weight: bold; + display: flex; + justify-content: center; +} + +.deleted { + color: red; +} + +.form-group { + display: flex; + justify-content:space-between; +} \ No newline at end of file diff --git a/notes/templates/create_note.html b/notes/templates/create_note.html index a2f8764..a5e8cdb 100644 --- a/notes/templates/create_note.html +++ b/notes/templates/create_note.html @@ -4,12 +4,30 @@ Create Secret Note + {% load static %} + -

Create a Secret Note

-
- {% csrf_token %} {{ form.as_p }} - -
+
+

Create a Secret Note

+
+ {% csrf_token %} +
+ {{ form.non_field_errors }} + + {{ form.content }} +
+
+ + {{ form.max_views }} +
+
+ + {{ form.expiration_hours }} +
+ +
+

Back to Home

+
diff --git a/notes/templates/index.html b/notes/templates/index.html new file mode 100644 index 0000000..5b8fee1 --- /dev/null +++ b/notes/templates/index.html @@ -0,0 +1,40 @@ + + + + + + Secret Note + {% load static %} + + + +
+

Welcome to Secret Notes

+ +
+

Create a New Note

+

+ Securely create a note that will be accessible through a unique URL + link. +

+ Create a New Secret Note +
+ +
+

View an Existing Note

+

Enter the URL key of the note you want to view:

+
+ + +
+
+
+ + diff --git a/notes/templates/note_created.html b/notes/templates/note_created.html index 12ffe00..b537fb5 100644 --- a/notes/templates/note_created.html +++ b/notes/templates/note_created.html @@ -3,15 +3,20 @@ + {% load static %} + Note Created

Your Secret Note has been created

+

URL Key: {{ note.url_key }}

+

Share this link to view the note:

{% url 'view_note' note.url_key %}

+

Back to Home

diff --git a/notes/templates/note_expired.html b/notes/templates/note_expired.html index 3a462cf..6516f38 100644 --- a/notes/templates/note_expired.html +++ b/notes/templates/note_expired.html @@ -3,10 +3,13 @@ + {% load static %} + Note Expired

Note Expired

This note has expired or has already been viewed.

+

Back to Home

diff --git a/notes/templates/note_view.html b/notes/templates/note_view.html index 958e1b8..c18843e 100644 --- a/notes/templates/note_view.html +++ b/notes/templates/note_view.html @@ -3,13 +3,16 @@ + {% load static %} + View Secret Note

Secret Note

{{ content }}

{% if deleted %} -

This note has now been deleted

+

This note has now been deleted

{% endif %} +

Back to Home

diff --git a/notes/templates/ratelimit_error.html b/notes/templates/ratelimit_error.html index ce37f1e..a83eb1a 100644 --- a/notes/templates/ratelimit_error.html +++ b/notes/templates/ratelimit_error.html @@ -3,10 +3,13 @@ + {% load static %} + Rate Limit Exceeded

Rate Limit Exceeded

You have exceeded the rate limit. Please try again later.

+

Back to Home

diff --git a/notes/urls.py b/notes/urls.py index 72cfe95..f41bdab 100644 --- a/notes/urls.py +++ b/notes/urls.py @@ -3,6 +3,7 @@ from . import views urlpatterns = [ + path("", views.index, name="index"), path("create/", views.create_note, name="create_note"), path("note//", views.view_note, name="view_note"), ] diff --git a/notes/views.py b/notes/views.py index 817a337..09e39ad 100644 --- a/notes/views.py +++ b/notes/views.py @@ -1,4 +1,4 @@ -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django_ratelimit.decorators import ratelimit @@ -6,6 +6,15 @@ from .models import SecretNote +@ratelimit(key="ip", rate="5/m", method=["GET"]) +def index(request): + url_key = request.GET.get("url_key") + if url_key: + return redirect("view_note", url_key=url_key) + + return render(request, "index.html") + + @ratelimit(key="ip", rate="5/m", method=["GET", "POST"]) def create_note(request): if request.method == "POST": @@ -27,8 +36,14 @@ def create_note(request): @ratelimit(key="ip", rate="10/m", method=["GET"]) -def view_note(request, url_key): +def view_note(request, url_key=None): + if not url_key or url_key == "00000000-0000-0000-0000-000000000000": + url_key = request.GET.get("url_key") + if not url_key: + return redirect("index") + note = get_object_or_404(SecretNote, url_key=url_key) + if note.is_expired(): note.delete() return render(request, "note_expired.html") From 2039cc7d0dbef674add4931f65d7e912d9163f06 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 20:53:28 +0300 Subject: [PATCH 22/34] feat: add custom 404 error handling middleware and a template for it --- notes/middleware.py | 15 ++++++++++++++- notes/models.py | 2 +- notes/templates/404.html | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 notes/templates/404.html diff --git a/notes/middleware.py b/notes/middleware.py index ebc5de4..5c45cbb 100644 --- a/notes/middleware.py +++ b/notes/middleware.py @@ -1,4 +1,4 @@ -from django.http import HttpResponseForbidden +from django.http import HttpResponseForbidden, HttpResponseNotFound from django.shortcuts import render from django_ratelimit.exceptions import Ratelimited @@ -13,3 +13,16 @@ def __call__(self, request): def process_exception(self, request, exception): if isinstance(exception, Ratelimited): return render(request, "ratelimit_error.html", status=429) + + +class Custom404Middleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + if response.status_code == 404: + return render(request, "404.html", status=404) + + return response diff --git a/notes/models.py b/notes/models.py index 3e3a637..d75984f 100644 --- a/notes/models.py +++ b/notes/models.py @@ -8,7 +8,7 @@ class SecretNote(models.Model): content = models.TextField() url_key = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) expiration_time = models.DateTimeField(null=True, blank=True) - max_views = models.IntegerField(default=1) + max_views = models.PositiveIntegerField(default=1) views = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) diff --git a/notes/templates/404.html b/notes/templates/404.html new file mode 100644 index 0000000..98b5134 --- /dev/null +++ b/notes/templates/404.html @@ -0,0 +1,21 @@ + + + + + + + 404 - Page Not Found + {% load static %} + + + +
+

404 - Page Not Found

+
+

Oops! The page you're looking for doesn't exist.

+

It seems you've ventured into uncharted territory. Don't worry, it happens to the best of us!

+
+

Back to Home

+
+ + \ No newline at end of file From 6b1dd791e55cf83bc15c3ce2165f9fc8f25ee8ff Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Wed, 9 Oct 2024 23:38:46 +0300 Subject: [PATCH 23/34] feat: add user authentication, linked SecretNote to User model and added login and registratoin templates --- ...retnote_user_alter_secretnote_max_views.py | 26 +++++ notes/models.py | 2 + notes/static/styles.css | 105 ++++++++++++++---- notes/templates/index.html | 22 +++- notes/templates/login.html | 28 +++++ notes/templates/register.html | 24 ++++ notes/urls.py | 19 ++++ notes/views.py | 18 +++ 8 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py create mode 100644 notes/templates/login.html create mode 100644 notes/templates/register.html diff --git a/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py b/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py new file mode 100644 index 0000000..80d4d87 --- /dev/null +++ b/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.1 on 2024-10-09 19:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='secretnote', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='secretnote', + name='max_views', + field=models.PositiveIntegerField(default=1), + ), + ] diff --git a/notes/models.py b/notes/models.py index d75984f..052d855 100644 --- a/notes/models.py +++ b/notes/models.py @@ -1,5 +1,6 @@ import uuid +from django.contrib.auth.models import User from django.db import models from django.utils import timezone @@ -11,6 +12,7 @@ class SecretNote(models.Model): max_views = models.PositiveIntegerField(default=1) views = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) def is_expired(self): if self.expiration_time and timezone.now() > self.expiration_time: diff --git a/notes/static/styles.css b/notes/static/styles.css index fb3eff6..1c6ee23 100644 --- a/notes/static/styles.css +++ b/notes/static/styles.css @@ -6,18 +6,9 @@ body { padding: 0; } +.form-container, .container { - max-width: 800px; - margin: 50px auto; - padding: 20px; - background: #ffffff; - border-radius: 12px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); - overflow: hidden; -} - -.form-container { - max-width: 400px; + max-width: 400px; margin: 100px auto; padding: 30px; background: #ffffff; @@ -25,22 +16,31 @@ body { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); } +.container { + max-width: 600px; +} + h1, -h2 { +h2, +p { text-align: center; +} + +h1, +h2 { color: #2c3e50; } form { display: flex; flex-direction: column; - justify-content: center; gap: 15px; padding: 20px; } input[type="text"], -textarea { +textarea, +.form-group input { padding: 12px; border: 1px solid #ddd; border-radius: 6px; @@ -55,7 +55,8 @@ textarea:focus { border-color: #5cb85c; } -button { +button, +.form-container button { padding: 12px; background-color: #5cb85c; border: none; @@ -64,27 +65,27 @@ button { font-size: 16px; cursor: pointer; transition: background-color 0.3s; + width: 100%; } button:hover { background-color: #4cae4c; } -a { +a.button { + padding: 12px; +} + +a:not(.button) { color: #3498db; text-decoration: none; transition: color 0.3s; } -a:hover { +a:not(.button):hover { color: #2980b9; } -p { - text-align: center; - font-size: 18px; -} - .card { padding: 20px; margin: 20px 0; @@ -106,5 +107,61 @@ p { .form-group { display: flex; - justify-content:space-between; -} \ No newline at end of file + flex-direction: column; + margin-bottom: 15px; +} + +.form-group label { + margin-bottom: 5px; + font-weight: bold; +} + +.error, +.errorlist { + color: #dc3545; + font-size: 14px; + margin-top: 5px; +} + +.errorlist { + list-style-type: none; + padding: 0; + margin: 5px 0; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.user-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.button { + padding: 8px 16px; + background-color: #5cb85c; + border: none; + border-radius: 6px; + color: #ffffff; + font-size: 14px; + cursor: pointer; + transition: background-color 0.3s; + text-decoration: none; +} + +.button:hover { + background-color: #4cae4c; +} + +.logout-button { + background-color: #d9534f; +} + +.logout-button:hover { + background-color: #c9302c; +} diff --git a/notes/templates/index.html b/notes/templates/index.html index 5b8fee1..227b80b 100644 --- a/notes/templates/index.html +++ b/notes/templates/index.html @@ -9,7 +9,27 @@
-

Welcome to Secret Notes

+
+

Welcome to Secret Notes

+ {% if user.is_authenticated %} + + {% else %} + + {% endif %} +

Create a New Note

diff --git a/notes/templates/login.html b/notes/templates/login.html new file mode 100644 index 0000000..c1e72e0 --- /dev/null +++ b/notes/templates/login.html @@ -0,0 +1,28 @@ + + + + + + Login + {% load static %} + + + +
+

Login

+
+ {% csrf_token %} + + {% for field in form %} +
{{ field.label_tag }} {{ field }}
+ {% if field.errors %} + {{ field.errors|striptags }} + {% endif %} {% endfor %} + +
+

+ Don't have an account? Register here +

+
+ + diff --git a/notes/templates/register.html b/notes/templates/register.html new file mode 100644 index 0000000..9d4e64f --- /dev/null +++ b/notes/templates/register.html @@ -0,0 +1,24 @@ + + + + + + Register + {% load static %} + + + +
+

Register

+
+ {% csrf_token %} {% for field in form %} +
{{ field.label_tag }} {{ field }}
+ {% if field.errors %} + {{ field.errors|striptags }} + {% endif %} {% endfor %} + +
+

Already have an account? Login here

+
+ + diff --git a/notes/urls.py b/notes/urls.py index f41bdab..600ebde 100644 --- a/notes/urls.py +++ b/notes/urls.py @@ -1,4 +1,6 @@ +from django.contrib.auth import views as auth_views from django.urls import path +from django.views.generic import RedirectView from . import views @@ -6,4 +8,21 @@ path("", views.index, name="index"), path("create/", views.create_note, name="create_note"), path("note//", views.view_note, name="view_note"), + path( + "accounts/login/", + auth_views.LoginView.as_view( + template_name="login.html", + redirect_authenticated_user=True, + ), + name="login", + ), + path( + "accounts/logout/", + auth_views.LogoutView.as_view(next_page="index"), + name="logout", + ), + path("accounts/register/", views.register, name="register"), + path( + "accounts/profile/", RedirectView.as_view(pattern_name="index", permanent=False) + ), ] diff --git a/notes/views.py b/notes/views.py index 09e39ad..f36aa0c 100644 --- a/notes/views.py +++ b/notes/views.py @@ -1,3 +1,6 @@ +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import UserCreationForm from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django_ratelimit.decorators import ratelimit @@ -15,12 +18,14 @@ def index(request): return render(request, "index.html") +@login_required @ratelimit(key="ip", rate="5/m", method=["GET", "POST"]) def create_note(request): if request.method == "POST": form = SecretNoteForm(request.POST) if form.is_valid(): note = form.save(commit=False) + note.user = request.user if form.cleaned_data["expiration_hours"]: note.expiration_time = timezone.now() + timezone.timedelta( @@ -61,3 +66,16 @@ def view_note(request, url_key=None): def ratelimit_error(request, exception=None): return render(request, "ratelimit_error.html", status=429) + + +def register(request): + if request.method == "POST": + form = UserCreationForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + return redirect("index") + else: + form = UserCreationForm() + + return render(request, "register.html", {"form": form}) From 007fa93fb96f6c1f322619c22ee226dda371fbee Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Thu, 10 Oct 2024 13:39:55 +0300 Subject: [PATCH 24/34] test: add tests for user authentication, note creation, and note viewing functionality --- notes/tests.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/notes/tests.py b/notes/tests.py index 7704fe6..7e352f6 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone @@ -25,6 +26,15 @@ def test_is_expired(self): ) self.assertFalse(valid_note.is_expired()) + def test_increment_view(self): + note = SecretNote.objects.create(content="Test note", max_views=2) + self.assertEqual(note.views, 0) + note.increment_view() + self.assertEqual(note.views, 1) + note.increment_view() + self.assertEqual(note.views, 2) + self.assertTrue(note.is_expired()) + class SecretNoteFormTest(TestCase): def test_valid_form(self): @@ -40,10 +50,12 @@ def test_invalid_form(self): class NoteCreationViewTest(TestCase): - def setUP(self): + def setUp(self): self.client = Client() + self.user = User.objects.create_user(username="testuser", password="12345") - def test_create_note(self): + def test_create_note_authenticated(self): + self.client.login(username="testuser", password="12345") response = self.client.post( reverse("create_note"), {"content": "Test note content", "max_views": 3, "expiration_hours": 24}, @@ -52,6 +64,14 @@ def test_create_note(self): self.assertContains(response, "Your Secret Note has been created") self.assertEqual(SecretNote.objects.count(), 1) + def test_create_note_unauthenticated(self): + response = self.client.post( + reverse("create_note"), + {"content": "Test note content", "max_views": 3, "expiration_hours": 24}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(SecretNote.objects.count(), 0) + class NoteViewTest(TestCase): def setUp(self): @@ -67,3 +87,67 @@ def test_view_note(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "This note has now been deleted") self.assertEqual(SecretNote.objects.count(), 0) + + def test_view_expired_note(self): + expired_note = SecretNote.objects.create( + content="Expired note", + expiration_time=timezone.now() - timezone.timedelta(hours=1), + ) + response = self.client.get(reverse("view_note", args=[expired_note.url_key])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Note Expired") + self.assertEqual(SecretNote.objects.count(), 1) + + +class IndexViewTest(TestCase): + def setUp(self): + self.client = Client() + + def test_index_view(self): + response = self.client.get(reverse("index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Create a New Note") + + def test_index_view_with_url_key(self): + note = SecretNote.objects.create(content="Test note content") + response = self.client.get(reverse("index") + f"?url_key={note.url_key}") + self.assertRedirects(response, reverse("view_note", args=[note.url_key])) + + +class UserAuthTest(TestCase): + def setUp(self): + self.client = Client() + self.register_url = reverse("register") + self.login_url = reverse("login") + self.logout_url = reverse("logout") + self.user = User.objects.create_user(username="testuser", password="12345") + + def test_user_registration(self): + response = self.client.post( + self.register_url, + { + "username": "newuser", + "password1": "testpass123", + "password2": "testpass123", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(User.objects.filter(username="newuser").exists()) + + def test_user_login(self): + response = self.client.post( + self.login_url, + { + "username": "testuser", + "password": "12345", + }, + ) + self.assertEqual(response.status_code, 302) + + def test_user_logout(self): + self.client.login(username="testuser", password="12345") + response = self.client.post(self.logout_url) + self.assertEqual(response.status_code, 302) + + response = self.client.get(reverse("create_note")) + self.assertEqual(response.status_code, 302) From a93aa2f1d6992a8e57d16be4e2022fc60d4119fe Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Thu, 10 Oct 2024 15:03:36 +0300 Subject: [PATCH 25/34] test: add integration tests for note creation, viewing, and user flows --- notes/integration_tests.py | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 notes/integration_tests.py diff --git a/notes/integration_tests.py b/notes/integration_tests.py new file mode 100644 index 0000000..284d177 --- /dev/null +++ b/notes/integration_tests.py @@ -0,0 +1,117 @@ +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from .models import SecretNote + + +class IntegrationTests(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user(username="testuser", password="12345") + self.client.login(username="testuser", password="12345") + + def test_create_and_view_note(self): + create_url = reverse("create_note") + create_data = { + "content": "This is a test note", + "max_views": 2, + "expiration_hours": 24, + } + response = self.client.post(create_url, create_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Your Secret Note has been created") + + url_key = response.context["note"].url_key + + view_url = reverse("view_note", args=[url_key]) + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This is a test note") + + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This note has now been deleted") + + response = self.client.get(view_url) + self.assertEqual(response.status_code, 404) + + def test_note_expiration(self): + note = SecretNote.objects.create( + content="This note will expire soon", + user=self.user, + expiration_time=timezone.now() + timezone.timedelta(hours=1), + ) + + view_url = reverse("view_note", args=[note.url_key]) + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This note will expire soon") + + note.expiration_time = timezone.now() - timezone.timedelta(minutes=1) + note.save() + + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertContains( + response, "This note has expired or has already been viewed." + ) + + def test_user_flow(self): + self.client.logout() + + register_url = reverse("register") + register_data = { + "username": "newuser", + "password1": "complexpassword123", + "password2": "complexpassword123", + } + response = self.client.post(register_url, register_data) + self.assertEqual(response.status_code, 302) + self.assertTrue(User.objects.filter(username="newuser").exists()) + + index_url = reverse("index") + response = self.client.get(index_url) + self.assertIn(response.status_code, [200, 429]) + + if response.status_code == 429: + self.assertContains( + response, + "You have exceeded the rate limit. Please try again later.", + status_code=429, + ) + else: + self.assertContains(response, "Create a New Note") + + create_url = reverse("create_note") + create_data = { + "content": "Note created by new user", + "max_views": 1, + "expiration_hours": 24, + } + response = self.client.post(create_url, create_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Your Secret Note has been created") + + logout_url = reverse("logout") + response = self.client.post(logout_url) + self.assertIn(response.status_code, [200, 302]) + + response = self.client.get(create_url) + self.assertRedirects(response, f"{reverse('login')}?next={create_url}") + + def test_rate_limiting(self): + view_url = reverse("index") + + for _ in range(5): + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + + response = self.client.get(view_url) + self.assertEqual(response.status_code, 429) + self.assertContains( + response, + "You have exceeded the rate limit. Please try again later.", + status_code=429, + ) From 183c533b772e8d3ce126f05eea6f3656dff48b5c Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Thu, 10 Oct 2024 22:53:38 +0300 Subject: [PATCH 26/34] test: add end-to-end tests --- notes/e2e_tests.py | 132 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 notes/e2e_tests.py diff --git a/notes/e2e_tests.py b/notes/e2e_tests.py new file mode 100644 index 0000000..21f593d --- /dev/null +++ b/notes/e2e_tests.py @@ -0,0 +1,132 @@ +import logging +import time + +from django.contrib.auth.models import User +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.core.cache import cache +from django.urls import reverse +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +logging.basicConfig(level=logging.INFO) + + +class SecretNotesE2ETests(StaticLiveServerTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + chrome_options = Options() + chrome_options.add_argument("--headless") + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--disable-dev-shm-usage") + cls.selenium = webdriver.Chrome(options=chrome_options) + cls.selenium.implicitly_wait(10) + + def setUp(self): + super().setUp() + cache.clear() + + @classmethod + def tearDownClass(cls): + cls.selenium.quit() + super().tearDownClass() + + def wait_for_element(self, by, value, timeout=20): + try: + element = WebDriverWait(self.selenium, timeout).until( + EC.presence_of_element_located((by, value)) + ) + logging.info(f"Found element: {by}={value}") + return element + except Exception as e: + logging.error(f"Failed to find element {by}={value}: {str(e)}") + logging.error(f"Current URL: {self.selenium.current_url}") + logging.error(f"Page source: {self.selenium.page_source}") + raise + + def check_for_rate_limit(self): + if "Rate Limit Exceeded" in self.selenium.page_source: + logging.warning( + "Rate limit exceeded. Waiting for 60 seconds before retrying." + ) + time.sleep(60) + self.selenium.refresh() + + def test_register_login_create_view_note(self): + logging.info("Starting test_register_login_create_view_note") + + self.selenium.get(f"{self.live_server_url}{reverse('register')}") + logging.info(f"Navigated to registration page: {self.selenium.current_url}") + + self.wait_for_element(By.NAME, "username").send_keys("testuser") + self.wait_for_element(By.NAME, "password1").send_keys("testpassword123") + self.wait_for_element(By.NAME, "password2").send_keys("testpassword123") + self.wait_for_element(By.XPATH, "//button[text()='Register']").click() + logging.info("Submitted registration form") + + self.wait_for_element(By.XPATH, "//span[contains(text(), 'Hello, testuser!')]") + logging.info("Registration successful") + + self.selenium.get(f"{self.live_server_url}{reverse('create_note')}") + logging.info(f"Navigated to create note page: {self.selenium.current_url}") + + self.check_for_rate_limit() + + content_input = self.wait_for_element(By.ID, "id_content") + content_input.send_keys("This is a test secret note") + + max_views_input = self.wait_for_element(By.NAME, "max_views") + max_views_input.clear() + max_views_input.send_keys("2") + + expiration_hours_input = self.wait_for_element(By.NAME, "expiration_hours") + expiration_hours_input.send_keys("24") + + self.wait_for_element(By.XPATH, "//button[text()='Create Note']").click() + + self.check_for_rate_limit() + + note_url = self.wait_for_element( + By.XPATH, "//a[contains(@href, '/note/')]" + ).get_attribute("href") + + self.selenium.get(self.live_server_url) + self.wait_for_element(By.XPATH, "//button[contains(text(), 'Logout')]").click() + + for _ in range(2): + self.selenium.get(note_url) + note_content = self.wait_for_element(By.TAG_NAME, "p").text + self.assertEqual(note_content, "This is a test secret note") + + self.selenium.get(note_url) + error_message = self.wait_for_element(By.TAG_NAME, "h1").text + self.assertEqual(error_message, "404 - Page Not Found") + + def test_view_nonexistent_note(self): + self.selenium.get( + f"{self.live_server_url}{reverse('view_note', kwargs={'url_key': '00000000-0000-0000-0000-000000000000'})}" + ) + error_message = self.wait_for_element(By.TAG_NAME, "h1").text + self.assertEqual(error_message, "404 - Page Not Found") + + def test_rate_limit(self): + User.objects.create_user(username="testuser", password="12345") + + self.selenium.get(f"{self.live_server_url}{reverse('login')}") + self.wait_for_element(By.NAME, "username").send_keys("testuser") + self.wait_for_element(By.NAME, "password").send_keys("12345") + self.wait_for_element(By.XPATH, "//button[text()='Login']").click() + + for i in range(16): + self.selenium.get(f"{self.live_server_url}{reverse('create_note')}") + logging.info(f"Attempt {i+1} to access create note page") + if "Rate Limit Exceeded" in self.selenium.page_source: + logging.info("Rate limit exceeded as expected") + break + time.sleep(0.1) + + error_message = self.wait_for_element(By.TAG_NAME, "h1").text + self.assertEqual(error_message, "Rate Limit Exceeded") From c1796d8c6540f3dc3672910580aaccef67363815 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Fri, 11 Oct 2024 12:19:25 +0300 Subject: [PATCH 27/34] ci: add GitHub Actions workflow for linting, formatting, and testing --- .github/workflows/ci.yaml | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..e8eb7a3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.10 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install flake8 + - name: Run linting + run: flake8 . + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.10 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black + - name: Run formatting check + run: black --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.10 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + run: | + python manage.py test + python manage.py test notes.integration_tests + python manage.py test notes.e2e_tests From 72ba30714d4078115b57ce0c1b76c48fa8576a3f Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Fri, 11 Oct 2024 19:45:38 +0300 Subject: [PATCH 28/34] chore(docker): add Dockerization for project deployment --- Dockerfile | 21 +++++++++++++++++++++ docker-compose.yaml | 29 +++++++++++++++++++++++++++++ requirements.txt | 18 ++++++++++++++++++ secret_notes_project/settings.py | 30 +++++++++++++++++++++--------- 4 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd16934 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/ +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . /app/ + +RUN mkdir -p /app/static + +RUN python manage.py collectstatic --noinput + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "secret_notes_project.wsgi:application"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f96db56 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + db: + image: postgres + volumes: + - ./data/db:/var/lib/postgresql/data + environment: + - POSTGRES_DB=secretnote + - POSTGRES_USER=secretnote + - POSTGRES_PASSWORD=secretnote + + web: + build: . + command: > + sh -c "python manage.py migrate && + python manage.py collectstatic --noinput && + gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8000" + volumes: + - .:/app + - static_volume:/app/staticfiles + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgres://secretnote:secretnote@db/secretnote + - DEBUG=False + depends_on: + - db + +volumes: + static_volume: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 89aa940..ed7d72c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,23 @@ asgiref==3.8.1 +attrs==24.2.0 +certifi==2024.8.30 Django==5.1.1 +django-ratelimit==4.1.0 +exceptiongroup==1.2.2 +h11==0.14.0 +idna==3.10 +outcome==1.3.0.post0 psycopg2-binary==2.9.9 +PySocks==1.7.1 +selenium==4.25.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 sqlparse==0.5.1 +trio==0.26.2 +trio-websocket==0.11.1 typing_extensions==4.12.2 +urllib3==2.2.3 +websocket-client==1.8.0 +wsproto==1.2.0 +gunicorn==20.1.0 +whitenoise==5.3.0 diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py index fb85c2f..2dc7ae4 100644 --- a/secret_notes_project/settings.py +++ b/secret_notes_project/settings.py @@ -24,9 +24,9 @@ SECRET_KEY = "django-insecure-gp1gt41n0po!a6$8!v3a)w=#053opjfcuj=0wv12jcbk$x*q2y" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ.get("DEBUG", "False") == "True" -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] # Application definition @@ -39,7 +39,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "notes", - "ratelimit", + # "ratelimit", ] MIDDLEWARE = [ @@ -50,8 +50,13 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "notes.middleware.RatelimitMiddleware", + "notes.middleware.Custom404Middleware", + "whitenoise.middleware.WhiteNoiseMiddleware", ] +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + ROOT_URLCONF = "secret_notes_project.urls" TEMPLATES = [ @@ -79,11 +84,11 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("DB_NAME", "secret_notes_db"), - "USER": os.environ.get("DB_USER", "postgres"), - "PASSWORD": os.environ.get("DB_PASSWORD", ""), - "HOST": os.environ.get("DB_HOST", "db"), - "PORT": os.environ.get("DB_PORT", "5432"), + "NAME": os.environ.get("POSTGRES_DB", "secretnote"), + "USER": os.environ.get("POSTGRES_USER", "secretnote"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secretnote"), + "HOST": "db", + "PORT": 5432, } } @@ -122,7 +127,14 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.1/howto/static-files/ -STATIC_URL = "static/" +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +# Additional locations of static files +STATICFILES_DIRS = [ + BASE_DIR / "static", +] + # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field From f43383de9dd6c918d89f71017ba839c0083b3f18 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Fri, 11 Oct 2024 20:54:02 +0300 Subject: [PATCH 29/34] docker: try to reduce image size --- Dockerfile | 19 ++++++++----------- docker-compose.yaml | 12 +++++++----- notes/views.py | 6 +++--- secret_notes_project/settings.py | 7 +++---- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd16934..a194c67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,18 @@ -FROM python:3.10-slim +FROM python:3.10-alpine -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache gcc musl-dev # Include musl-dev for Python package compilation -COPY requirements.txt /app/ -RUN pip install --upgrade pip && pip install -r requirements.txt +COPY requirements.txt . -COPY . /app/ +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt -RUN mkdir -p /app/static +COPY . . RUN python manage.py collectstatic --noinput -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "secret_notes_project.wsgi:application"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "secret_notes_project.wsgi:application"] diff --git a/docker-compose.yaml b/docker-compose.yaml index f96db56..7050a3c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,20 +7,22 @@ services: - POSTGRES_DB=secretnote - POSTGRES_USER=secretnote - POSTGRES_PASSWORD=secretnote + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 web: build: . command: > - sh -c "python manage.py migrate && - python manage.py collectstatic --noinput && - gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8000" + sh -c "until nc -z db 5432; do echo waiting for db; sleep 1; done && + python manage.py migrate && + python manage.py collectstatic --noinput && + gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8000" volumes: - .:/app - - static_volume:/app/staticfiles ports: - "8000:8000" environment: - - DATABASE_URL=postgres://secretnote:secretnote@db/secretnote + - DATABASE_URL=postgres://secretnote:secretnote@db:5432/secretnote - DEBUG=False depends_on: - db diff --git a/notes/views.py b/notes/views.py index f36aa0c..f9ff7e3 100644 --- a/notes/views.py +++ b/notes/views.py @@ -9,7 +9,7 @@ from .models import SecretNote -@ratelimit(key="ip", rate="5/m", method=["GET"]) +@ratelimit(key="ip", rate="15/m", method=["GET"]) def index(request): url_key = request.GET.get("url_key") if url_key: @@ -19,7 +19,7 @@ def index(request): @login_required -@ratelimit(key="ip", rate="5/m", method=["GET", "POST"]) +@ratelimit(key="ip", rate="15/m", method=["GET", "POST"]) def create_note(request): if request.method == "POST": form = SecretNoteForm(request.POST) @@ -40,7 +40,7 @@ def create_note(request): return render(request, "create_note.html", {"form": form}) -@ratelimit(key="ip", rate="10/m", method=["GET"]) +@ratelimit(key="ip", rate="15/m", method=["GET"]) def view_note(request, url_key=None): if not url_key or url_key == "00000000-0000-0000-0000-000000000000": url_key = request.GET.get("url_key") diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py index 2dc7ae4..7138af0 100644 --- a/secret_notes_project/settings.py +++ b/secret_notes_project/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get("DEBUG", "False") == "True" -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] +ALLOWED_HOSTS = ["*"] # Application definition @@ -39,7 +39,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "notes", - # "ratelimit", ] MIDDLEWARE = [ @@ -87,8 +86,8 @@ "NAME": os.environ.get("POSTGRES_DB", "secretnote"), "USER": os.environ.get("POSTGRES_USER", "secretnote"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secretnote"), - "HOST": "db", - "PORT": 5432, + "HOST": os.environ.get("POSTGRES_HOST", "db"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), } } From d0d6d383ab6be5c8badfe3d472ece7a389ec5402 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 15 Oct 2024 20:45:32 +0300 Subject: [PATCH 30/34] feat(deployment): enhance dockerization and add Helm chart for secret-note app deployment --- Dockerfile | 8 +++-- docker-compose.yaml | 19 ++++++----- requirements.txt | 1 + secret-note/.helmignore | 23 +++++++++++++ secret-note/Chart.yaml | 5 +++ secret-note/templates/configmap.yaml | 11 ++++++ secret-note/templates/db-deployment.yaml | 31 +++++++++++++++++ secret-note/templates/db-pvc.yaml | 10 ++++++ secret-note/templates/db-service.yaml | 10 ++++++ secret-note/templates/deployment.yaml | 43 ++++++++++++++++++++++++ secret-note/templates/secret.yaml | 8 +++++ secret-note/templates/service.yaml | 11 ++++++ secret-note/values.yaml | 28 +++++++++++++++ secret_notes_project/settings.py | 13 ++----- wait_for_db.sh | 5 +++ 15 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 secret-note/.helmignore create mode 100644 secret-note/Chart.yaml create mode 100644 secret-note/templates/configmap.yaml create mode 100644 secret-note/templates/db-deployment.yaml create mode 100644 secret-note/templates/db-pvc.yaml create mode 100644 secret-note/templates/db-service.yaml create mode 100644 secret-note/templates/deployment.yaml create mode 100644 secret-note/templates/secret.yaml create mode 100644 secret-note/templates/service.yaml create mode 100644 secret-note/values.yaml create mode 100644 wait_for_db.sh diff --git a/Dockerfile b/Dockerfile index a194c67..9714fa0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,14 +5,16 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN apk add --no-cache gcc musl-dev # Include musl-dev for Python package compilation +RUN apk add --no-cache gcc musl-dev COPY requirements.txt . - RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt COPY . . RUN python manage.py collectstatic --noinput -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "secret_notes_project.wsgi:application"] +COPY wait_for_db.sh /wait_for_db.sh +RUN chmod +x /wait_for_db.sh + +CMD sh -c "/wait_for_db.sh && python manage.py migrate && python manage.py collectstatic --noinput && exec gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8008" diff --git a/docker-compose.yaml b/docker-compose.yaml index 7050a3c..6fcc0db 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,31 +1,32 @@ services: - db: + secret-note-db: image: postgres volumes: - - ./data/db:/var/lib/postgresql/data + - ./data/secret-note-db:/var/lib/postgresql/data environment: - POSTGRES_DB=secretnote - POSTGRES_USER=secretnote - POSTGRES_PASSWORD=secretnote - - POSTGRES_HOST=db + - POSTGRES_HOST=secret-note-db - POSTGRES_PORT=5432 + - DATABASE_URL=postgres://secretnote:secretnote@secret-note-db:5432/secretnote web: build: . command: > - sh -c "until nc -z db 5432; do echo waiting for db; sleep 1; done && + sh -c "until nc -z secret-note-db 5432; do echo waiting for secret-note-db; sleep 1; done && python manage.py migrate && python manage.py collectstatic --noinput && - gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8000" + gunicorn secret_notes_project.wsgi:application --bind 0.0.0.0:8008" volumes: - .:/app ports: - - "8000:8000" + - "8008:8008" environment: - - DATABASE_URL=postgres://secretnote:secretnote@db:5432/secretnote + - DATABASE_URL=postgres://secretnote:secretnote@secret-note-db:5432/secretnote - DEBUG=False depends_on: - - db + - secret-note-db volumes: - static_volume: \ No newline at end of file + static_volume: diff --git a/requirements.txt b/requirements.txt index ed7d72c..1c8dde8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ websocket-client==1.8.0 wsproto==1.2.0 gunicorn==20.1.0 whitenoise==5.3.0 +dj-database-url==2.2.0 \ No newline at end of file diff --git a/secret-note/.helmignore b/secret-note/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/secret-note/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/secret-note/Chart.yaml b/secret-note/Chart.yaml new file mode 100644 index 0000000..b60c5d8 --- /dev/null +++ b/secret-note/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: secret-note +description: A Helm chart for Secret Note application with integrated PostgreSQL +version: 0.1.0 +appVersion: "1.0.0" diff --git a/secret-note/templates/configmap.yaml b/secret-note/templates/configmap.yaml new file mode 100644 index 0000000..d2e5a55 --- /dev/null +++ b/secret-note/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: secret-note-config +data: + POSTGRES_DB: {{ .Values.database.name | quote }} + POSTGRES_USER: {{ .Values.database.user | quote }} + POSTGRES_HOST: {{ .Values.database.host | quote }} + POSTGRES_PORT: {{ .Values.database.port | quote }} + DATABASE_URL: {{ .Values.database.url | quote }} + DEBUG: {{ .Values.debug | quote }} diff --git a/secret-note/templates/db-deployment.yaml b/secret-note/templates/db-deployment.yaml new file mode 100644 index 0000000..7b97d5b --- /dev/null +++ b/secret-note/templates/db-deployment.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-note-db +spec: + replicas: 1 + selector: + matchLabels: + app: secret-note-db + template: + metadata: + labels: + app: secret-note-db + spec: + containers: + - name: postgres + image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" + ports: + - containerPort: 5432 + envFrom: + - configMapRef: + name: secret-note-config + - secretRef: + name: secret-note-secret + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: secret-note-db-pvc diff --git a/secret-note/templates/db-pvc.yaml b/secret-note/templates/db-pvc.yaml new file mode 100644 index 0000000..5f1625f --- /dev/null +++ b/secret-note/templates/db-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: secret-note-db-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.postgresql.storage.size }} diff --git a/secret-note/templates/db-service.yaml b/secret-note/templates/db-service.yaml new file mode 100644 index 0000000..6c93be8 --- /dev/null +++ b/secret-note/templates/db-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: secret-note-db +spec: + ports: + - port: 5432 + selector: + app: secret-note-db + clusterIP: None diff --git a/secret-note/templates/deployment.yaml b/secret-note/templates/deployment.yaml new file mode 100644 index 0000000..e494e15 --- /dev/null +++ b/secret-note/templates/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-note +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: secret-note + template: + metadata: + labels: + app: secret-note + spec: + containers: + - name: secret-note + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: 8008 + envFrom: + - configMapRef: + name: secret-note-config + - secretRef: + name: secret-note-secret + env: + - name: DATABASE_URL + value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)" + command: ["gunicorn", "secret_notes_project.wsgi:application", "--bind", "0.0.0.0:8008"] + + initContainers: + - name: migrate + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + envFrom: + - configMapRef: + name: secret-note-config + - secretRef: + name: secret-note-secret + env: + - name: DATABASE_URL + value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)" + command: ["python", "manage.py", "migrate"] diff --git a/secret-note/templates/secret.yaml b/secret-note/templates/secret.yaml new file mode 100644 index 0000000..663d334 --- /dev/null +++ b/secret-note/templates/secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: secret-note-secret +type: Opaque +data: + POSTGRES_PASSWORD: {{ .Values.database.password | b64enc }} + SECRET_KEY: {{ .Values.secretKey | b64enc }} diff --git a/secret-note/templates/service.yaml b/secret-note/templates/service.yaml new file mode 100644 index 0000000..6e7790c --- /dev/null +++ b/secret-note/templates/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: secret-note +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: 8008 + selector: + app: secret-note diff --git a/secret-note/values.yaml b/secret-note/values.yaml new file mode 100644 index 0000000..fffc775 --- /dev/null +++ b/secret-note/values.yaml @@ -0,0 +1,28 @@ +replicaCount: 1 + +image: + repository: mohamedfadel01/secret-note + tag: latest + pullPolicy: IfNotPresent + +service: + type: LoadBalancer + port: 8008 + +database: + name: secretnote + user: secretnote + password: secretnote + host: secret-note-db + port: "5432" + url: postgres://secretnote:secretnote@secret-note-db:5432/secretnote + +secretKey: "django-insecure-gp1gt41n0po!a6$8!v3a)w=#053opjfcuj=0wv12jcbk$x*q2y" +debug: "False" + +postgresql: + image: + repository: postgres + tag: 17.0-alpine3.20 + storage: + size: 1Gi diff --git a/secret_notes_project/settings.py b/secret_notes_project/settings.py index 7138af0..1b271b8 100644 --- a/secret_notes_project/settings.py +++ b/secret_notes_project/settings.py @@ -13,6 +13,8 @@ import os from pathlib import Path +import dj_database_url + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -80,16 +82,7 @@ # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB", "secretnote"), - "USER": os.environ.get("POSTGRES_USER", "secretnote"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secretnote"), - "HOST": os.environ.get("POSTGRES_HOST", "db"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - } -} +DATABASES = {"default": dj_database_url.config(default=os.environ.get("DATABASE_URL"))} # Password validation diff --git a/wait_for_db.sh b/wait_for_db.sh new file mode 100644 index 0000000..8a23a9b --- /dev/null +++ b/wait_for_db.sh @@ -0,0 +1,5 @@ +#!/bin/sh +until python manage.py dbshell -c "SELECT 1;" > /dev/null 2>&1; do + echo "Waiting for database to be ready..." + sleep 1 +done From 8c629dbe8d727958c6bf39326cf564d6ce4490af Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 15 Oct 2024 22:07:59 +0300 Subject: [PATCH 31/34] feat: update CI workflow and add test settings --- .github/workflows/ci.yaml | 80 ++++++++++++++------------- notes/integration_tests.py | 2 +- secret_notes_project/test_settings.py | 15 +++++ 3 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 secret_notes_project/test_settings.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8eb7a3..d77d273 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,56 +2,58 @@ name: CI on: push: - branches: [ main, development ] + branches: [main, development] pull_request: - branches: [ main ] + branches: [main] jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.10 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install flake8 - - name: Run linting - run: flake8 . + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install flake8 + - name: Run linting + run: flake8 . format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.10 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install black - - name: Run formatting check - run: black --check . + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black + - name: Run formatting check + run: black --check . test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.10 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run tests - run: | - python manage.py test - python manage.py test notes.integration_tests - python manage.py test notes.e2e_tests + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + env: + DJANGO_SETTINGS_MODULE: secret_notes_project.test_settings + run: | + python manage.py test + python manage.py test notes.integration_tests + python manage.py test notes.e2e_tests diff --git a/notes/integration_tests.py b/notes/integration_tests.py index 284d177..2a82ee6 100644 --- a/notes/integration_tests.py +++ b/notes/integration_tests.py @@ -104,7 +104,7 @@ def test_user_flow(self): def test_rate_limiting(self): view_url = reverse("index") - for _ in range(5): + for _ in range(15): response = self.client.get(view_url) self.assertEqual(response.status_code, 200) diff --git a/secret_notes_project/test_settings.py b/secret_notes_project/test_settings.py new file mode 100644 index 0000000..6309190 --- /dev/null +++ b/secret_notes_project/test_settings.py @@ -0,0 +1,15 @@ +import tempfile + +from .settings import * + +STATIC_ROOT = tempfile.mkdtemp() + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "test_db.sqlite3", + } +} + +DEBUG = True +ALLOWED_HOSTS = ["*"] From 7233f390cfd831dc210635d613db866f7f636362 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 15 Oct 2024 22:19:24 +0300 Subject: [PATCH 32/34] feat: add flake8 configuration and update CI workflow --- .flake8 | 9 ++++++ .github/workflows/ci.yaml | 4 ++- manage.py | 4 +-- notes/apps.py | 4 +-- notes/migrations/0001_initial.py | 30 ++++++++++++------- ...retnote_user_alter_secretnote_max_views.py | 17 +++++++---- secret_notes_project/asgi.py | 2 +- secret_notes_project/wsgi.py | 2 +- 8 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..1ad4197 --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +exclude = + migrations, + __pycache__, + manage.py, + settings.py, + env +max-line-length = 88 +extend-ignore = E203 \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d77d273..416a8a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,9 @@ jobs: pip install -r requirements.txt pip install flake8 - name: Run linting - run: flake8 . + run: | + flake8 . + black --check . format: runs-on: ubuntu-latest diff --git a/manage.py b/manage.py index 16067f1..05497c0 100755 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secret_notes_project.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "secret_notes_project.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/notes/apps.py b/notes/apps.py index 832dd3f..8c63a92 100644 --- a/notes/apps.py +++ b/notes/apps.py @@ -2,5 +2,5 @@ class NotesConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'notes' + default_auto_field = "django.db.models.BigAutoField" + name = "notes" diff --git a/notes/migrations/0001_initial.py b/notes/migrations/0001_initial.py index 2b4e358..c9387d7 100644 --- a/notes/migrations/0001_initial.py +++ b/notes/migrations/0001_initial.py @@ -8,20 +8,30 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='SecretNote', + name="SecretNote", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.TextField()), - ('url_key', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('expiration_time', models.DateTimeField(blank=True, null=True)), - ('max_views', models.IntegerField(default=1)), - ('views', models.IntegerField(default=0)), - ('created_at', models.DateTimeField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField()), + ( + "url_key", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("expiration_time", models.DateTimeField(blank=True, null=True)), + ("max_views", models.IntegerField(default=1)), + ("views", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), ], ), ] diff --git a/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py b/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py index 80d4d87..17ab6a4 100644 --- a/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py +++ b/notes/migrations/0002_secretnote_user_alter_secretnote_max_views.py @@ -8,19 +8,24 @@ class Migration(migrations.Migration): dependencies = [ - ('notes', '0001_initial'), + ("notes", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='secretnote', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="secretnote", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='secretnote', - name='max_views', + model_name="secretnote", + name="max_views", field=models.PositiveIntegerField(default=1), ), ] diff --git a/secret_notes_project/asgi.py b/secret_notes_project/asgi.py index acd084e..3319df2 100644 --- a/secret_notes_project/asgi.py +++ b/secret_notes_project/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secret_notes_project.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "secret_notes_project.settings") application = get_asgi_application() diff --git a/secret_notes_project/wsgi.py b/secret_notes_project/wsgi.py index c542f5f..a27addc 100644 --- a/secret_notes_project/wsgi.py +++ b/secret_notes_project/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secret_notes_project.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "secret_notes_project.settings") application = get_wsgi_application() From af57de1d66d76a63686e24fb0a9ae528a4eb68ce Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 15 Oct 2024 22:40:02 +0300 Subject: [PATCH 33/34] fix: try to fix github actions errors --- .github/workflows/ci.yaml | 18 ------------------ notes/admin.py | 3 --- notes/middleware.py | 1 - 3 files changed, 22 deletions(-) delete mode 100644 notes/admin.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 416a8a4..571fb13 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,24 +7,6 @@ on: branches: [main] jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install flake8 - - name: Run linting - run: | - flake8 . - black --check . - format: runs-on: ubuntu-latest steps: diff --git a/notes/admin.py b/notes/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/notes/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/notes/middleware.py b/notes/middleware.py index 5c45cbb..735f121 100644 --- a/notes/middleware.py +++ b/notes/middleware.py @@ -1,4 +1,3 @@ -from django.http import HttpResponseForbidden, HttpResponseNotFound from django.shortcuts import render from django_ratelimit.exceptions import Ratelimited From d3a990c09372d31e32a566b814ec9b231019a541 Mon Sep 17 00:00:00 2001 From: Mohamed Fadel Date: Tue, 15 Oct 2024 22:59:27 +0300 Subject: [PATCH 34/34] docs: update README with project details, setup, and deployment instructions --- README.md | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 361f402..b8ee38e 100644 --- a/README.md +++ b/README.md @@ -1 +1,120 @@ -# SecretNote-MVC-MohamedFadel \ No newline at end of file +# Secret Notes Project + +Secret Notes is a Django-based web application that allows users to create and share secure, self-destructing notes. The project is containerized using Docker and can be easily deployed using Docker Compose or Kubernetes. + +## Features + +- Create secure, encrypted notes +- Set expiration time for notes +- View notes using unique URL keys +- User registration and authentication +- Rate limiting to prevent abuse +- Dockerized application for easy deployment + +## Prerequisites + +- Docker and Docker Compose +- Kubernetes (optional, for k8s deployment) + +## Quick Start + +1. Clone the repository: + + ``` + git clone https://github.com/codescalersinternships/SecretNote-MVC-MohamedFadel/tree/development + cd SecretNote-MVC-MohamedFadel + ``` + +2. Start the application using Docker Compose: + + ``` + docker-compose up --build + ``` + +3. Access the application at `http://localhost:8008` + +## Project Structure + +``` +. +├── docker-compose.yaml +├── Dockerfile +├── manage.py +├── notes/ # Main application +├── README.md +├── requirements.txt +├── secret-note/ # Helm chart for Kubernetes deployment +├── secret_notes_project/ # Django project settings +├── staticfiles/ +└── wait_for_db.sh +``` + +## Development + +To set up the development environment: + +1. Create a virtual environment: + + ``` + python -m venv venv + source venv/bin/activate + ``` + +2. Install dependencies: + + ``` + pip install -r requirements.txt + ``` + +3. Run migrations: + + ``` + python manage.py migrate + ``` + +4. Start the development server: + ``` + python manage.py runserver + ``` + +## Testing + +The project includes various test files: + +- `notes/tests.py`: Unit tests +- `notes/integration_tests.py`: Integration tests +- `notes/e2e_tests.py`: End-to-end tests + +To run the tests: + +``` +python manage.py test +``` + +## Deployment + +### Docker Compose + +Use the provided `docker-compose.yaml` file to deploy the application: + +``` +docker-compose up --build +``` + +### Kubernetes + +A Helm chart is provided in the `secret-note` directory for Kubernetes deployment. To deploy using Helm: + +1. Install Helm (if not already installed) +2. From the project root, run: + ``` + helm install secret-notes ./secret-note + ``` + +## Configuration + +The main configuration files are: + +- `secret_notes_project/settings.py`: Django settings +- `docker-compose.yaml`: Docker Compose configuration +- `secret-note/values.yaml`: Helm chart values for Kubernetes deployment