From 2c160228c284a2a25d297fb4b8251caa58468842 Mon Sep 17 00:00:00 2001 From: Indra Sigicharla Date: Mon, 16 Jun 2025 10:37:32 -0400 Subject: [PATCH 1/4] Added nginx, REST, and password reset --- code/.env.example | 48 ++- code/Dockerfile | 76 +--- code/Readme.md | 24 +- code/docker-cmd.sh | 36 +- code/docker-compose-dev.yml | 19 - code/docker-compose.yml | 41 +- code/docker-entrypoint.sh | 4 - code/mymedic/mymedic/__init__.py | 11 + code/mymedic/mymedic/settings.py | 132 +++--- code/mymedic/mymedic/urls.py | 16 - code/mymedic/pytest.ini | 3 - code/mymedic/static/css/dashboard.css | 2 +- code/mymedic/static/css/form.css | 1 - code/mymedic/static/css/login.css | 71 ++++ code/mymedic/static/js/dashboard.js | 2 +- code/mymedic/static/js/login.js | 34 ++ code/mymedic/static/js/medical_records.js | 8 +- code/mymedic/static/js/register.js | 44 ++ code/mymedic/tests/users/test_auth.py | 2 +- code/mymedic/tests/users/test_forms.py | 36 +- .../tests/users/test_medical_records.py | 2 +- code/mymedic/users/forms.py | 8 +- .../migrations/0005_passwordresettoken.py | 28 ++ code/mymedic/users/models.py | 24 +- .../users/templates/users/dashboard.html | 2 +- .../templates/users/forgot_password.html | 86 ++++ code/mymedic/users/templates/users/login.html | 68 ++-- .../templates/users/medical_records.html | 2 +- .../users/templates/users/profile.html | 2 +- .../users/templates/users/register.html | 74 ++-- .../users/templates/users/reset_password.html | 73 ++++ code/mymedic/users/urls.py | 42 +- code/mymedic/users/views.py | 378 ++++++++++++------ code/nginx.conf | 33 ++ code/requirements.txt | 3 +- 35 files changed, 974 insertions(+), 461 deletions(-) delete mode 100644 code/docker-compose-dev.yml delete mode 100644 code/mymedic/pytest.ini create mode 100644 code/mymedic/static/css/login.css create mode 100644 code/mymedic/static/js/login.js create mode 100644 code/mymedic/static/js/register.js create mode 100644 code/mymedic/users/migrations/0005_passwordresettoken.py create mode 100644 code/mymedic/users/templates/users/forgot_password.html create mode 100644 code/mymedic/users/templates/users/reset_password.html create mode 100644 code/nginx.conf diff --git a/code/.env.example b/code/.env.example index 73524b61..2c02028c 100644 --- a/code/.env.example +++ b/code/.env.example @@ -1,2 +1,46 @@ -DJANGO_SECRET_KEY= -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] +# Gunicorn Configuration +GUNICORN_PORT=8000 +GUNICORN_WORKERS=2 +GUNICORN_TIMEOUT=60 +GUNICORN_LOG_LEVEL=info + +# Django Security +DJANGO_SECRET_KEY='' +DJANGO_DEBUG=false +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,localhost:8080 +DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1,http://localhost,http://localhost:8080 + +# Database +SQL_ENGINE=django.db.backends.sqlite3 +SQL_DATABASE=db.sqlite3 +DJANGO_SQLITE_DIR=/sqlite + +# API Configuration +REACT_APP_API_URL=http://127.0.0.1:8080/api + +# Localization +DJANGO_LANGUAGE_CODE=en-us +DJANGO_TIME_ZONE=UTC + +# Email Configuration - Gmail +DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +DJANGO_EMAIL_HOST=smtp.gmail.com +DJANGO_EMAIL_PORT=587 +DJANGO_EMAIL_USE_TLS=true +DJANGO_EMAIL_USE_SSL=false +DJANGO_EMAIL_HOST_USER=email@gmail.com +DJANGO_EMAIL_HOST_PASSWORD= +DJANGO_DEFAULT_FROM_EMAIL=MyMedic + +# Default Email Settings +DJANGO_SERVER_EMAIL=email@gmail.com + +# Project Configuration +PROJECT_NAME=mymedic + +# Admin Configuration +DJANGO_ADMIN_NAME=Admin +DJANGO_ADMIN_EMAIL=email@gmail.com +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_EMAIL=admin@admin.com +DJANGO_SUPERUSER_PASSWORD=admin \ No newline at end of file diff --git a/code/Dockerfile b/code/Dockerfile index d8c75050..dee9f27f 100644 --- a/code/Dockerfile +++ b/code/Dockerfile @@ -1,66 +1,26 @@ -FROM python:3.13-alpine AS base +FROM python:3.13-alpine -FROM base AS builder +RUN apk update \ + && apk add --no-cache \ + gcc musl-dev python3-dev libffi-dev openssl-dev \ + nginx su-exec -RUN apk update && apk --no-cache add python3-dev libpq-dev && mkdir /install -WORKDIR /install -COPY requirements.txt ./ -RUN pip install --no-cache-dir --prefix=/install -r ./requirements.txt +RUN mkdir -p /usr/src/mymedic /sqlite /var/run/nginx +WORKDIR /usr/src/mymedic -FROM base +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -ARG USER=user -ARG USER_UID=1001 -ARG PROJECT_NAME=mymedic -ARG GUNICORN_PORT=8000 -ARG GUNICORN_WORKERS=2 -# the value is in seconds -ARG GUNICORN_TIMEOUT=60 -ARG GUNICORN_LOG_LEVEL=info -ARG DJANGO_BASE_DIR=/usr/src/$PROJECT_NAME -ARG DJANGO_STATIC_ROOT=/var/www/static -ARG DJANGO_MEDIA_ROOT=/var/www/media -ARG DJANGO_SQLITE_DIR=/sqlite +COPY . . -# if no superuser (currently: Admin, Admin, indhhra@bu.edu) -ARG DJANGO_SUPERUSER_USERNAME=admin -ARG DJANGO_SUPERUSER_PASSWORD=admin -ARG DJANGO_SUPERUSER_EMAIL=admin@example.com -ARG DJANGO_DEV_SERVER_PORT=8000 +COPY nginx.conf /etc/nginx/nginx.conf +RUN adduser -D -u 1001 user \ + && chown -R user:user /usr/src/mymedic /sqlite \ + && chmod +x /usr/src/mymedic/docker-entrypoint.sh \ + /usr/src/mymedic/docker-cmd.sh -ENV \ - USER=$USER \ - USER_UID=$USER_UID \ - PROJECT_NAME=$PROJECT_NAME \ - GUNICORN_PORT=$GUNICORN_PORT \ - GUNICORN_WORKERS=$GUNICORN_WORKERS \ - GUNICORN_TIMEOUT=$GUNICORN_TIMEOUT \ - GUNICORN_LOG_LEVEL=$GUNICORN_LOG_LEVEL \ - DJANGO_BASE_DIR=$DJANGO_BASE_DIR \ - DJANGO_STATIC_ROOT=$DJANGO_STATIC_ROOT \ - DJANGO_MEDIA_ROOT=$DJANGO_MEDIA_ROOT \ - DJANGO_SQLITE_DIR=$DJANGO_SQLITE_DIR \ - DJANGO_SUPERUSER_USERNAME=$DJANGO_SUPERUSER_USERNAME \ - DJANGO_SUPERUSER_PASSWORD=$DJANGO_SUPERUSER_PASSWORD \ - DJANGO_SUPERUSER_EMAIL=$DJANGO_SUPERUSER_EMAIL \ - DJANGO_DEV_SERVER_PORT=$DJANGO_DEV_SERVER_PORT +EXPOSE 80 8000 -COPY --from=builder /install /usr/local -COPY docker-entrypoint.sh / -COPY docker-cmd.sh / -COPY $PROJECT_NAME $DJANGO_BASE_DIR - -# User -RUN chmod +x /docker-entrypoint.sh /docker-cmd.sh && \ - apk --no-cache add su-exec libpq-dev && \ - mkdir -p $DJANGO_STATIC_ROOT $DJANGO_MEDIA_ROOT $DJANGO_SQLITE_DIR && \ - adduser -s /bin/sh -D -u $USER_UID $USER && \ - chown -R $USER:$USER $DJANGO_BASE_DIR $DJANGO_STATIC_ROOT $DJANGO_MEDIA_ROOT $DJANGO_SQLITE_DIR - -WORKDIR $DJANGO_BASE_DIR - -RUN python manage.py collectstatic --noinput && \ - python manage.py makemigrations && \ - python manage.py migrate && \ - python manage.py createsuperuser --noinput \ No newline at end of file +ENTRYPOINT ["/usr/src/mymedic/docker-entrypoint.sh"] +CMD ["/usr/src/mymedic/docker-cmd.sh"] diff --git a/code/Readme.md b/code/Readme.md index 6b9ef64b..b2c72ec9 100644 --- a/code/Readme.md +++ b/code/Readme.md @@ -17,33 +17,15 @@ cp .env.example .env ``` Edit `.env` and add a DJANGO_SECRET_KEY generated from [Django Secret Key Generator](https://djecrety.ir/). ---- - -### 3. Build the Docker Image - -```bash -docker build -t mymedic . -``` --- -### 4. Run the Server - -#### Running Tests -```bash -docker run --rm mymedic pytest -v tests/ -``` - -#### Run in Development Mode - -```bash -docker compose -f docker-compose-dev.yml up -d -``` +### 3. Run the Server #### Run in Production Mode ```bash -docker compose up -d +docker compose up --build ``` -This binds the local website code into the container and serves it at `http://127.0.0.1:8080`. +This binds the local website code into the container and serves it at `http://localhost:8080`. \ No newline at end of file diff --git a/code/docker-cmd.sh b/code/docker-cmd.sh index 17e6b049..ebe7c7ae 100644 --- a/code/docker-cmd.sh +++ b/code/docker-cmd.sh @@ -1,14 +1,34 @@ +#!/bin/sh +set -e + +MANAGE_PY=$(find / -type f -name manage.py -print -quit) +if [ -z "$MANAGE_PY" ]; then + echo "Error: manage.py not found under /" >&2 + exit 1 +fi +cd "$(dirname "$MANAGE_PY")" + +su-exec "$USER" python manage.py migrate --noinput + su-exec "$USER" python manage.py collectstatic --noinput -USER_EXISTS="from django.contrib.auth import get_user_model; User = get_user_model(); exit(User.objects.exists())" -su-exec "$USER" python manage.py shell -c "$USER_EXISTS" && su-exec "$USER" python manage.py createsuperuser --noinput +USER_EXISTS_CMD="from django.contrib.auth import get_user_model; U = get_user_model(); exit(0) if U.objects.exists() else exit(1)" +if su-exec "$USER" python manage.py shell -c "$USER_EXISTS_CMD"; then + echo "Superuser already exists" +else + su-exec "$USER" python manage.py createsuperuser --noinput \ + --username "$DJANGO_SUPERUSER_USERNAME" \ + --email "$DJANGO_SUPERUSER_EMAIL" +fi + +nginx if [ "$1" = "--debug" ]; then - exec su-exec "$USER" python manage.py runserver "0.0.0.0:$DJANGO_DEV_SERVER_PORT" + exec su-exec "$USER" python manage.py runserver "0.0.0.0:${DJANGO_DEV_SERVER_PORT:-8000}" else - exec su-exec "$USER" gunicorn "$PROJECT_NAME.wsgi:application" \ - --bind "0.0.0.0:$GUNICORN_PORT" \ - --workers "$GUNICORN_WORKERS" \ - --timeout "$GUNICORN_TIMEOUT" \ - --log-level "$GUNICORN_LOG_LEVEL" + exec su-exec "$USER" gunicorn "${PROJECT_NAME}.wsgi:application" \ + --bind "0.0.0.0:${GUNICORN_PORT:-8000}" \ + --workers "${GUNICORN_WORKERS:-3}" \ + --timeout "${GUNICORN_TIMEOUT:-30}" \ + --log-level "${GUNICORN_LOG_LEVEL:-info}" fi diff --git a/code/docker-compose-dev.yml b/code/docker-compose-dev.yml deleted file mode 100644 index 2e90448e..00000000 --- a/code/docker-compose-dev.yml +++ /dev/null @@ -1,19 +0,0 @@ -services: - mymedic: - build: . - image: mymedic - - # Map the host port 8080 to the container port 8000 to get the UI running - ports: - - 8080:8000 - - # Set the environment variables based on your .env file - env_file: - - .env - - # Mount the local directory to the container directory for the website - volumes: - - ./mymedic:/usr/src/mymedic - - # Command to run the Django development server - command: sh -c "python manage.py makemigrations && python manage.py migrate && python manage.py runserver 0.0.0:8000" \ No newline at end of file diff --git a/code/docker-compose.yml b/code/docker-compose.yml index 8e5f7a66..c06f274f 100644 --- a/code/docker-compose.yml +++ b/code/docker-compose.yml @@ -1,7 +1,42 @@ services: mymedic: + container_name: mymedic build: . - image: mymedic + image: mymedic:latest + env_file: + - .env + restart: unless-stopped + + volumes: + - .:/usr/src/mymedic:rw + - sqlite-data:/sqlite + - staticfiles-data:/usr/share/nginx/html/static + - media-data:/usr/share/nginx/html/media + + working_dir: /usr/src/mymedic + + expose: + - "8000" + + entrypoint: ["/usr/src/mymedic/docker-entrypoint.sh"] + command: ["/usr/src/mymedic/docker-cmd.sh"] + + nginx: + image: nginx:1.25-alpine + restart: unless-stopped + depends_on: + - mymedic ports: - - 8080:8000 - command: /docker-entrypoint.sh /docker-cmd.sh \ No newline at end of file + - "8080:80" + volumes: + - staticfiles-data:/usr/share/nginx/html/static:ro + - media-data:/usr/share/nginx/html/media:ro + - ./nginx.conf:/etc/nginx/nginx.conf:ro + +volumes: + sqlite-data: + driver: local + staticfiles-data: + driver: local + media-data: + driver: local diff --git a/code/docker-entrypoint.sh b/code/docker-entrypoint.sh index 72037450..31b5dad5 100644 --- a/code/docker-entrypoint.sh +++ b/code/docker-entrypoint.sh @@ -1,8 +1,4 @@ #!/bin/sh -# vim:sw=4:ts=4:et set -e - -su-exec "$USER" python manage.py migrate --noinput - exec "$@" diff --git a/code/mymedic/mymedic/__init__.py b/code/mymedic/mymedic/__init__.py index e69de29b..b9fa6794 100644 --- a/code/mymedic/mymedic/__init__.py +++ b/code/mymedic/mymedic/__init__.py @@ -0,0 +1,11 @@ +from typing import List, Optional + +TRUE = ("1", "true", "True", "TRUE", "on", "yes") + + +def is_true(val: Optional[str]) -> bool: + return val in TRUE + + +def split_with_comma(val: str) -> List[str]: + return list(filter(None, map(str.strip, val.split(",")))) diff --git a/code/mymedic/mymedic/settings.py b/code/mymedic/mymedic/settings.py index c7a1793d..294ca771 100644 --- a/code/mymedic/mymedic/settings.py +++ b/code/mymedic/mymedic/settings.py @@ -1,47 +1,44 @@ -""" -Django settings for mymedic project. - -Generated by 'django-admin startproject' using Django 5.2.2. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ -""" - -from pathlib import Path import os +from pathlib import Path +from dotenv import load_dotenv +from mymedic import is_true -# 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.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-8nk@m)abtpw$+3zxm028g18+@o4w0ig%@k!t9-zckbdre@bv5b' +load_dotenv() -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +def split_with_comma(s: str): + return [part.strip() for part in s.split(",") if part.strip()] -ALLOWED_HOSTS = ["localhost", "127.0.0.1"] +BASE_DIR = Path(__file__).resolve().parent.parent +INSECURE_KEY = 'django-insecure-8nk@m)abtpw$+3zxm028g18+@o4w0ig%@k!t9-zckbdre@bv5b' +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", INSECURE_KEY) +DEBUG = is_true(os.getenv("DJANGO_DEBUG", "false")) +ALLOWED_HOSTS = split_with_comma( + os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,localhost:8080") +) +CSRF_TRUSTED_ORIGINS = split_with_comma( + os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "") +) -# Application definition +_default_host = ALLOWED_HOSTS[0] if ALLOWED_HOSTS else "localhost" +_scheme = os.getenv("FRONTEND_SCHEME", "http") +FRONTEND_URL = os.getenv("FRONTEND_URL", f"{_scheme}://{_default_host}") INSTALLED_APPS = [ + 'corsheaders', + 'rest_framework', + 'rest_framework_simplejwt', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'users', # Custom app for user management + 'users', ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -56,7 +53,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'users' / 'templates'], # Directory for custom templates + 'DIRS': [BASE_DIR / 'users' / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -70,58 +67,51 @@ WSGI_APPLICATION = 'mymedic.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': os.getenv('SQL_ENGINE', 'django.db.backends.sqlite3'), + 'NAME': BASE_DIR / os.getenv('SQL_DATABASE', 'db.sqlite3'), } } - -# Password validation -# https://docs.djangoproject.com/en/5.2/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', - }, + {'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'}, ] +LANGUAGE_CODE = os.getenv('DJANGO_LANGUAGE_CODE', 'en-us') +TIME_ZONE = os.getenv('DJANGO_TIME_ZONE', 'UTC') +USE_I18N = True +USE_TZ = True -# Internationalization -# https://docs.djangoproject.com/en/5.2/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.2/howto/static-files/ +STATIC_URL = '/static/' STATIC_ROOT = os.getenv("DJANGO_STATIC_ROOT", "/usr/share/nginx/html/static") -STATIC_URL = '/static/' -STATICFILES_DIRS = [ - BASE_DIR / 'static', # Directory for static files -] - -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field +STATICFILES_DIRS = [BASE_DIR / 'static'] DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +EMAIL_BACKEND = os.getenv( + "DJANGO_EMAIL_BACKEND", + "django.core.mail.backends.console.EmailBackend" if DEBUG else "django.core.mail.backends.smtp.EmailBackend" +) +EMAIL_HOST = os.getenv("DJANGO_EMAIL_HOST", "localhost") +EMAIL_PORT = int(os.getenv("DJANGO_EMAIL_PORT", 25)) +EMAIL_HOST_USER = os.getenv("DJANGO_EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD= os.getenv("DJANGO_EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = is_true(os.getenv("DJANGO_EMAIL_USE_TLS", "False")) +EMAIL_USE_SSL = is_true(os.getenv("DJANGO_EMAIL_USE_SSL", "False")) +DEFAULT_FROM_EMAIL = os.getenv("DJANGO_DEFAULT_FROM_EMAIL", EMAIL_HOST_USER) +SERVER_EMAIL = os.getenv("DJANGO_SERVER_EMAIL", DEFAULT_FROM_EMAIL) + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), +} + +CORS_ALLOW_ALL_ORIGINS = True diff --git a/code/mymedic/mymedic/urls.py b/code/mymedic/mymedic/urls.py index 1f855749..2f8024f3 100644 --- a/code/mymedic/mymedic/urls.py +++ b/code/mymedic/mymedic/urls.py @@ -1,19 +1,3 @@ -""" -URL configuration for mymedic project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/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, include diff --git a/code/mymedic/pytest.ini b/code/mymedic/pytest.ini deleted file mode 100644 index da5c7969..00000000 --- a/code/mymedic/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = mymedic.settings -python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/code/mymedic/static/css/dashboard.css b/code/mymedic/static/css/dashboard.css index d9d1ca12..cf6728dc 100644 --- a/code/mymedic/static/css/dashboard.css +++ b/code/mymedic/static/css/dashboard.css @@ -435,4 +435,4 @@ .dashboard-main { padding: 1rem 0; } -} +} \ No newline at end of file diff --git a/code/mymedic/static/css/form.css b/code/mymedic/static/css/form.css index c0595786..84482ed5 100644 --- a/code/mymedic/static/css/form.css +++ b/code/mymedic/static/css/form.css @@ -6,7 +6,6 @@ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); width: 532px; backdrop-filter: blur(10px); - transform: scale(1.4); margin: 10 auto; } diff --git a/code/mymedic/static/css/login.css b/code/mymedic/static/css/login.css new file mode 100644 index 00000000..f6cd94c7 --- /dev/null +++ b/code/mymedic/static/css/login.css @@ -0,0 +1,71 @@ +body { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Roboto', 'Helvetica Neue', Arial, sans-serif; +} + +.auth-box { + background: white; + padding: 3.5rem; + border-radius: 21px; + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); + width: 100%; + max-width: 532px; + backdrop-filter: blur(10px); +} + +.form-control { + margin-bottom: 1rem; + border-radius: 8px; + border: 2px solid #e9ecef; + padding: 0.75rem 1rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.form-control:focus { + border-color: #20b2aa; + box-shadow: 0 0 0 0.2rem rgba(32, 178, 170, 0.25); +} + +.btn { + border-radius: 8px; + padding: 0.75rem; + font-weight: 600; + transition: background-color 0.3s ease, border-color 0.3s ease, + box-shadow 0.3s ease; +} + +.btn-success { + background-color: #20b2aa !important; + border-color: #20b2aa !important; +} + +.btn-success:hover:not(:disabled) { + background-color: #1a9999 !important; + border-color: #1a9999 !important; + box-shadow: 0 5px 15px rgba(32, 178, 170, 0.3); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +a { + color: #6c757d !important; + font-size: 0.9rem; + text-decoration: none !important; + transition: color 0.3s ease; +} + +a:hover { + color: #20b2aa !important; +} + +.display-6 { + color: #20b2aa !important; +} diff --git a/code/mymedic/static/js/dashboard.js b/code/mymedic/static/js/dashboard.js index 805e76f6..1511b99b 100644 --- a/code/mymedic/static/js/dashboard.js +++ b/code/mymedic/static/js/dashboard.js @@ -44,4 +44,4 @@ document.addEventListener("DOMContentLoaded", function () { countElement.textContent = recordCount; } } -}); +}); \ No newline at end of file diff --git a/code/mymedic/static/js/login.js b/code/mymedic/static/js/login.js new file mode 100644 index 00000000..4eead9b5 --- /dev/null +++ b/code/mymedic/static/js/login.js @@ -0,0 +1,34 @@ +document.addEventListener("DOMContentLoaded", () => { + const form = document.getElementById("login-form"); + const loginMsg = document.getElementById("login-msg"); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + loginMsg.textContent = ""; + + const payload = { + username: document.getElementById("username").value.trim(), + password: document.getElementById("password").value + }; + + try { + const res = await fetch(API_LOGIN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken + }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + + if (res.ok) { + window.location.href = "/dashboard/"; + } else { + loginMsg.textContent = data.error || "Login failed."; + } + } catch (err) { + loginMsg.textContent = "Server error. Please try again."; + } + }); +}); diff --git a/code/mymedic/static/js/medical_records.js b/code/mymedic/static/js/medical_records.js index a426fb48..3587100c 100644 --- a/code/mymedic/static/js/medical_records.js +++ b/code/mymedic/static/js/medical_records.js @@ -25,12 +25,14 @@ document.addEventListener("DOMContentLoaded", function () { { title: "Flu Vaccination", date: "2025-03-12", summary: "Administered seasonal influenza vaccine. No adverse reactions." }, { title: "Dermatology Consultation", date: "2025-04-13", summary: "Eczema well-controlled. Continue current treatment regimen." }, { title: "Eye Examination", date: "2025-05-14", summary: "Vision remains stable at 20/20. Annual follow-up recommended." }, - { title: "Dental Extraction", date: "2025-08-05", summary: "One extraction due to a deep cavity." }, + { title: "MRI Scan (Knee)", date: "2025-06-15", summary: "Significant improvement in inflammation. Continue physiotherapy." } ]; + // Update dashboard stats + localStorage.setItem("medical_records_count", records.length); + function loadRecords() { container.innerHTML = ""; - localStorage.setItem("medical_records_count", records.length); if (records.length === 0) { if (recordMessage) { @@ -86,4 +88,4 @@ document.addEventListener("DOMContentLoaded", function () { }); loadRecords(); -}); \ No newline at end of file +}); diff --git a/code/mymedic/static/js/register.js b/code/mymedic/static/js/register.js new file mode 100644 index 00000000..c538c56f --- /dev/null +++ b/code/mymedic/static/js/register.js @@ -0,0 +1,44 @@ +document.addEventListener("DOMContentLoaded", () => { + const form = document.getElementById("register-form"); + const msg = document.getElementById("register-msg"); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + msg.textContent = ""; + + const payload = { + username: document.getElementById("username").value.trim(), + password: document.getElementById("password").value, + email: document.getElementById("email").value.trim(), + first_name: document.getElementById("first_name").value.trim(), + last_name: document.getElementById("last_name").value.trim(), + phone: document.getElementById("phone").value.trim(), + date_of_birth: document.getElementById("dob").value + }; + + try { + const response = await fetch(API_REGISTER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrftoken + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if (response.ok) { + msg.className = "text-success text-center mt-2"; + msg.textContent = "Registration successful! Redirecting..."; + setTimeout(() => window.location.href = "/login/", 1500); + } else { + msg.className = "text-danger text-center mt-2"; + msg.textContent = data.error || "Registration failed."; + } + } catch (err) { + msg.className = "text-danger text-center mt-2"; + msg.textContent = "Server error. Please try again."; + } + }); +}); diff --git a/code/mymedic/tests/users/test_auth.py b/code/mymedic/tests/users/test_auth.py index 40de6cca..5c88ec1a 100644 --- a/code/mymedic/tests/users/test_auth.py +++ b/code/mymedic/tests/users/test_auth.py @@ -17,7 +17,7 @@ def user(db): @pytest.mark.django_db @pytest.mark.parametrize("url_name", [ - ("mlogin"), + ("login"), ("register"), ]) def test_public_pages(client, url_name): diff --git a/code/mymedic/tests/users/test_forms.py b/code/mymedic/tests/users/test_forms.py index 5bc0ddac..6e0c3b14 100644 --- a/code/mymedic/tests/users/test_forms.py +++ b/code/mymedic/tests/users/test_forms.py @@ -57,8 +57,8 @@ def test_valid_form(self): "username": "testuser", "password1": "gsid234#$", "password2": "gsid234#$", - "firstname": "Jane", - "lastname": "Doe", + "first_name": "Jane", + "last_name": "Doe", "email": "jane@gmail.com" } form = CustomUserCreationForm(data=data) @@ -69,32 +69,32 @@ def test_valid_form(self): "username": "", "password1": "gsid234#$", "password2": "gsid234#$", - "firstname": "Jane", - "lastname": "Doe", + "first_name": "Jane", + "last_name": "Doe", "email": "jane@gmail.com" }, { "username": "test", "password1": "gsid234#$", "password2": "gid234#$", - "firstname": "Jane", - "lastname": "Doe", + "first_name": "Jane", + "last_name": "Doe", "email": "jane@gmail.com" }, { "username": "testuser", "password1": "gsid234#$", "password2": "gsid234#$", - "firstname": "Jane", - "lastname": "Doe", + "first_name": "Jane", + "last_name": "Doe", "email": "" }, { "username": "testuser", "password1": "gsid234#$", "password2": "gsid234#$", - "firstname": "", - "lastname": "Doe", + "first_name": "", + "last_name": "Doe", "email": "jane@gmail.com" }, ]) @@ -107,8 +107,8 @@ class TestCustomUserUpdateForm: @pytest.mark.django_db def test_valid_form(self): data = { - "firstname": "John", - "lastname": "Doe", + "first_name": "John", + "last_name": "Doe", "email": "john@gmail.com", "phone": "1234567890", "birth_date": "1970-01-01" @@ -118,22 +118,22 @@ def test_valid_form(self): @pytest.mark.parametrize("data", [ { - "firstname": "John", - "lastname": "Doe", + "first_name": "John", + "last_name": "Doe", "email": "john@gmail.com", "phone": "123456789", "birth_date": "1970-01-01" }, { - "firstname": "John", - "lastname": "Doe", + "first_name": "John", + "last_name": "Doe", "email": "john", "phone": "1234567890", "birth_date": "1970-01-01" }, { - "firstname": "", - "lastname": "", + "first_name": "", + "last_name": "", "email": "john@gmail.com", "phone": "1234567890", "birth_date": "1970-01-01" diff --git a/code/mymedic/tests/users/test_medical_records.py b/code/mymedic/tests/users/test_medical_records.py index 9a4db569..f7c9b6ac 100644 --- a/code/mymedic/tests/users/test_medical_records.py +++ b/code/mymedic/tests/users/test_medical_records.py @@ -76,7 +76,7 @@ def test_medical_records_requires_login(client): """Test that medical records page requires authentication""" response = client.get('/records/') assert response.status_code == 302 - assert 'mlogin' in response.url + assert 'login' in response.url @pytest.mark.django_db diff --git a/code/mymedic/users/forms.py b/code/mymedic/users/forms.py index ba1d609f..3c03b79a 100644 --- a/code/mymedic/users/forms.py +++ b/code/mymedic/users/forms.py @@ -19,8 +19,8 @@ class CustomUserCreationForm(UserCreationForm): Inherits from Django's UserCreationForm. """ username = forms.CharField(widget=TextInput(attrs={'placeholder': 'Username', 'class': 'form-control'})) - firstname = forms.CharField(widget=TextInput(attrs={'placeholder': 'First Name', 'class': 'form-control'})) - lastname = forms.CharField(widget=TextInput(attrs={'placeholder': 'Last Name', 'class': 'form-control'})) + first_name = forms.CharField(widget=TextInput(attrs={'placeholder': 'First Name', 'class': 'form-control'})) + last_name = forms.CharField(widget=TextInput(attrs={'placeholder': 'Last Name', 'class': 'form-control'})) email = forms.EmailField(widget=EmailInput(attrs={'placeholder': 'Email', 'class': 'form-control'})) password1 = forms.CharField(widget=PasswordInput(attrs={'placeholder': 'Password', 'class': 'form-control'})) password2 = forms.CharField(widget=PasswordInput(attrs={'placeholder': 'Confirm Password', 'class': 'form-control'})) @@ -38,8 +38,8 @@ class CustomUserUpdateForm(forms.Form): Custom form for updating user information. Inherits from Django's ModelForm. """ - firstname = forms.CharField(widget=TextInput(attrs={'placeholder': 'First Name', 'class': 'form-control'})) - lastname = forms.CharField(widget=TextInput(attrs={'placeholder': 'Last Name', 'class': 'form-control'})) + first_name = forms.CharField(widget=TextInput(attrs={'placeholder': 'First Name', 'class': 'form-control'})) + last_name = forms.CharField(widget=TextInput(attrs={'placeholder': 'Last Name', 'class': 'form-control'})) email = forms.EmailField(widget=TextInput(attrs={'placeholder': 'Email', 'class': 'form-control'})) phone = forms.CharField(widget=TextInput(attrs={'placeholder': 'Phone Number', 'class': 'form-control'}), validators=[RegexValidator(regex=r'^\d{10}$', message='Phone number must be 10 digits.')]) diff --git a/code/mymedic/users/migrations/0005_passwordresettoken.py b/code/mymedic/users/migrations/0005_passwordresettoken.py new file mode 100644 index 00000000..eaf1b91b --- /dev/null +++ b/code/mymedic/users/migrations/0005_passwordresettoken.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.1 on 2025-06-15 17:54 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_medicalrecord'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PasswordResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('is_used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/code/mymedic/users/models.py b/code/mymedic/users/models.py index 968709ea..728e57ce 100644 --- a/code/mymedic/users/models.py +++ b/code/mymedic/users/models.py @@ -10,10 +10,13 @@ DOB Verified: βœ… Unit tested, reviewed """ + from django.db import models -from django.contrib.auth.models import User +from django.contrib.auth.models import User +from django.utils import timezone +import uuid +from datetime import timedelta -# Create your models here. class Patient(models.Model): """Database model for patient information""" username = models.CharField(max_length=150, unique=True) @@ -25,7 +28,7 @@ class Patient(models.Model): def __str__(self): return f"{self.first_name} {self.last_name} ({self.email})" - + class MedicalRecord(models.Model): """Database Model for patients' medical records history""" user = models.ForeignKey(Patient, on_delete=models.CASCADE) @@ -45,7 +48,7 @@ class Appointment(models.Model): def __str__(self): return f"{self.date} - {self.patient_name} with {self.doctor_name}" - + class Prescription(models.Model): patient = models.ForeignKey(Patient, on_delete=models.CASCADE) doctor = models.CharField(max_length=100) @@ -56,3 +59,16 @@ class Prescription(models.Model): def __str__(self): return f"{self.doctor} - {self.prescription}" + +class PasswordResetToken(models.Model): + """Password reset tokens with expiry and usage flags""" + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(default=timezone.now) + is_used = models.BooleanField(default=False) + + def is_valid(self): + return not self.is_used and (timezone.now() - self.created_at < timedelta(hours=1)) + + def __str__(self): + return f"ResetToken for {self.user.username} ({'used' if self.is_used else 'active'})" diff --git a/code/mymedic/users/templates/users/dashboard.html b/code/mymedic/users/templates/users/dashboard.html index 76f89e93..b86f525b 100644 --- a/code/mymedic/users/templates/users/dashboard.html +++ b/code/mymedic/users/templates/users/dashboard.html @@ -21,7 +21,7 @@

MyMedic

πŸ“Š Dashboard πŸ‘€ Profile - πŸšͺ Logout + πŸšͺ Logout diff --git a/code/mymedic/users/templates/users/forgot_password.html b/code/mymedic/users/templates/users/forgot_password.html new file mode 100644 index 00000000..7cae2de6 --- /dev/null +++ b/code/mymedic/users/templates/users/forgot_password.html @@ -0,0 +1,86 @@ +{% load static %} + + + + + + Forgot Password | MyMedic + + + + + +
+
+

MyMedic

+

Reset your password

+
+

Enter your username or email address and we'll send you a link to reset your password.

+ + + +

+
+ + + + \ No newline at end of file diff --git a/code/mymedic/users/templates/users/login.html b/code/mymedic/users/templates/users/login.html index aa7b40e8..cc7073dd 100644 --- a/code/mymedic/users/templates/users/login.html +++ b/code/mymedic/users/templates/users/login.html @@ -1,43 +1,35 @@ {% load static %} + - Login - - - - - - + Login | MyMedic + + + + - - - diff --git a/code/mymedic/users/templates/users/profile.html b/code/mymedic/users/templates/users/profile.html index d5e95118..fe3819ae 100644 --- a/code/mymedic/users/templates/users/profile.html +++ b/code/mymedic/users/templates/users/profile.html @@ -21,7 +21,7 @@

MyMedic

πŸ“Š Dashboard πŸ‘€ Profile - πŸšͺ Logout + πŸšͺ Logout diff --git a/code/mymedic/users/templates/users/register.html b/code/mymedic/users/templates/users/register.html index 1fdedbb5..38bfc565 100644 --- a/code/mymedic/users/templates/users/register.html +++ b/code/mymedic/users/templates/users/register.html @@ -1,42 +1,42 @@ {% load static %} + - Register - - - - - - + Register + + + + + + - -
-
-

MyMedic

-

Register

-
-
- {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - {% if form.errors %} -
-
    - {% for field in form %} - {% for error in field.errors %} -
  • {{ error|escape }}
  • - {% endfor %} - {% endfor %} - {% for error in form.non_field_errors %} -
  • {{ error|escape }}
  • - {% endfor %} -
-
- {% endif %} - -
-
-

Already have an account? Login here

+ +
+
+

MyMedic

+

Register

- \ No newline at end of file +
+ + + + + + + + +

+ +
+
+

Already have an account? Login here

+
+
+ + + + + diff --git a/code/mymedic/users/templates/users/reset_password.html b/code/mymedic/users/templates/users/reset_password.html new file mode 100644 index 00000000..292d2281 --- /dev/null +++ b/code/mymedic/users/templates/users/reset_password.html @@ -0,0 +1,73 @@ +{% load static %} + + + + + + Reset Password | MyMedic + + + + + + +
+

Choose a New Password

+
+ + + +
+

+
+ + + diff --git a/code/mymedic/users/urls.py b/code/mymedic/users/urls.py index b7d3f233..42092f7e 100644 --- a/code/mymedic/users/urls.py +++ b/code/mymedic/users/urls.py @@ -1,19 +1,39 @@ -"""URL Patterns for user-related views in the MyMedic app.""" - from django.urls import path +from django.shortcuts import redirect from . import views urlpatterns = [ - path('', views.mlogin, name='root'), # Default route to login page - path('mlogin', views.mlogin, name='mlogin'), - path('mlogout', views.mlogout, name='mlogout'), - path('register', views.register, name='register'), - path('dashboard', views.dashboard, name='dashboard'), - path('profile', views.profile, name='profile'), + # User authentication routes + path('login/', views.login_page, name='login'), + path('api/login/', views.login_api, name='api_login'), + path('logout/', views.logout_page, name='logout'), + path('api/logout/', views.logout_api, name='api_logout'), + + # Registration routes + path('register/', views.signup_page, name='register'), + path('api/register/', views.register, name='api_register'), + + # User dashboard and profile + path('dashboard/', views.dashboard, name='dashboard'), + path('profile/', views.profile, name='profile'), + + # Medical records path('records/', views.medical_records, name='medical_records'), + path('api/search/', views.search_records, name='api_search_records'), + + # Appointment management path('cancel//', views.cancel_appointment, name='cancel_appointment'), - path("privacy/", views.privacy_policy, name="privacy_policy"), - path('api/search/', views.search_records, name='search'), -] + # Password management + path('forgot-password/', views.forgot_password_page, name='forgot_password_page'), + path('api/forgot-password/', views.forgot_password, name='api_forgot_password'), + path('reset-password/', views.reset_password_page, name='reset_password_page'), + path('api/validate-reset-token/', views.validate_reset_token, name='api_validate_reset_token'), + path('api/reset-password/', views.reset_password, name='api_reset_password'), + # Privacy policy + path('privacy/', views.privacy_policy, name='privacy_policy'), + + # Default redirect + path('', lambda request: redirect('login')), +] diff --git a/code/mymedic/users/views.py b/code/mymedic/users/views.py index 740a7c38..31edfd4e 100644 --- a/code/mymedic/users/views.py +++ b/code/mymedic/users/views.py @@ -1,175 +1,289 @@ -""" -Views for rendering the registration, login, dashboard, profile, and logout. - -@ai-generated -Tool: GitHub Copilot -Generated on: 06-08-2025 -Modified by: Tyler Gonsalves -Modifications: Added error handling, function decorators and updated docstrings -Verified: βœ… Unit tested, reviewed -""" - -from django.shortcuts import render, redirect -from django.contrib.auth import login, authenticate, logout -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.http import HttpResponse +import json +import logging +from datetime import datetime -from django.contrib.auth.models import User -from .forms import CustomUserCreationForm, CustomAuthenticationForm, CustomUserUpdateForm -from django.contrib.auth.models import User -from .models import Patient, Prescription +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import HttpResponse, JsonResponse +from django.contrib.auth import authenticate, login as django_login, logout as django_logout from django.contrib.auth.decorators import login_required -from django.contrib import messages -from users.models import Appointment -from django.shortcuts import get_object_or_404 -from django.http import JsonResponse -from django.views.decorators.http import require_GET -import json +from django.contrib.auth.models import User +from django.core.mail import send_mail +from django.views.decorators.cache import never_cache from django.conf import settings +from django.urls import reverse + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError + +from .models import Patient, PasswordResetToken +from .forms import CustomUserUpdateForm +from users.models import Appointment + +logger = logging.getLogger(__name__) + + +@never_cache +def login_page(request): + if request.user.is_authenticated: + return redirect('dashboard') + return render(request, "users/login.html") +@api_view(['POST']) +def login_api(request): + username = request.data.get("username") + password = request.data.get("password") + user = authenticate(username=username, password=password) + if user: + django_login(request, user) + refresh = RefreshToken.for_user(user) + request.session['access_token'] = str(refresh.access_token) + return Response({ + "refresh": str(refresh), + "access": str(refresh.access_token), + "username": user.username, + "full_name": f"{user.first_name} {user.last_name}" + }) + return Response({"error": "Invalid credentials"}, status=400) + + +@never_cache +@login_required(login_url='login') +def logout_page(request): + django_logout(request) + response = redirect('login') + response.delete_cookie('sessionid') + response.delete_cookie('access_token') + return response + + +@api_view(['POST']) +def logout_api(request): + request.session.flush() + django_logout(request) + return Response({"message": "Logged out successfully"}) + + +@never_cache +def signup_page(request): + if request.user.is_authenticated: + return redirect('dashboard') + return render(request, "users/register.html") + + +@api_view(['POST']) def register(request): - """ - Handle user registration. - """ - form = CustomUserCreationForm() - if request.method == 'POST': - form = CustomUserCreationForm(request.POST) - if form.is_valid(): - form.save() - Patient.objects.create( - username=form.cleaned_data['username'], - first_name=form.cleaned_data['firstname'], - last_name=form.cleaned_data['lastname'], - email=form.cleaned_data['email'], - date_of_birth=None + username = request.data.get("username") + password = request.data.get("password") + email = request.data.get("email") + first_name = request.data.get("first_name") + last_name = request.data.get("last_name") + phone = request.data.get("phone", "") + dob = request.data.get("date_of_birth", None) + + if not all([username, password, email, first_name, last_name]): + return Response({"error": "Missing required fields"}, status=400) + if User.objects.filter(username=username).exists(): + return Response({"error": "Username exists"}, status=400) + if User.objects.filter(email=email).exists(): + return Response({"error": "Email exists"}, status=400) + + try: + user = User.objects.create_user( + username=username, + password=password, + email=email, + first_name=first_name, + last_name=last_name + ) + dob_date = None + if dob: + try: + dob_date = datetime.strptime(dob, '%Y-%m-%d').date() + except ValueError: + logger.warning(f"Invalid dob: {dob}") + Patient.objects.create( + username=user.username, + first_name=first_name, + last_name=last_name, + email=email, + phone_number=phone, + date_of_birth=dob_date + ) + if user.email: + send_mail( + 'Welcome to MyMedic', + f'Hi {first_name}, thanks for registering!', + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=True ) - return redirect("mlogin") - else: - messages.error(request, "Invalid registration credentials") - return render(request, 'users/register.html', {'form': form}) - -def mlogin(request): - """ - Handle user login. - """ - form = CustomAuthenticationForm() - if request.method == 'POST': - form = CustomAuthenticationForm(request, data=request.POST) - if form.is_valid(): - username = request.POST.get('username') - password = request.POST.get('password') - user = authenticate(request, username=username, password=password) - if user is not None: - login(request, user) - messages.success(request, "Login successful") - return redirect("dashboard") - messages.error(request, "Invalid credentials") - return redirect("mlogin") - messages.error(request, "Invalid credentials") - return redirect("mlogin") - return render(request, 'users/login.html', {'form': form}) - -def mlogout(request): - """ - Log out the current user and redirect to login. - """ - logout(request) - return redirect("mlogin") - -@login_required(login_url='mlogin') + return Response({"message": "Account created"}) + except Exception as e: + user.delete() + logger.error(f"Register error: {e}") + return Response({"error": "Failed to create account"}, status=500) + + +@never_cache def dashboard(request): - """ - Display user dashboard. - """ - appointments = Appointment.objects.filter(user=request.user).order_by('date') - return render(request, 'users/dashboard.html', {'appointments': appointments}) + token = request.COOKIES.get('access_token') or request.session.get('access_token') + if not token: + return redirect('login') + try: + jwt_auth = JWTAuthentication() + validated_token = jwt_auth.get_validated_token(token) + user = jwt_auth.get_user(validated_token) + appointments = Appointment.objects.filter(user=user).order_by('date') + return render(request, "users/dashboard.html", { + 'user': user, + 'appointments': appointments + }) + except (InvalidToken, TokenError): + return redirect('login') + + +@never_cache +def forgot_password_page(request): + if request.user.is_authenticated: + return redirect('dashboard') + return render(request, "users/forgot_password.html") + + +@api_view(['POST']) +def forgot_password(request): + identifier = request.data.get("username") + if not identifier: + return Response({"error": "Provide username/email"}, status=400) + try: + user = User.objects.get(email=identifier) if '@' in identifier \ + else User.objects.get(username=identifier) + token = PasswordResetToken.objects.create(user=user).token + reset_url = request.build_absolute_uri(f"http://localhost:8080/reset-password/?token={token}") + send_mail( + 'Reset your MyMedic password', + f'Click here to reset your password:\n\n{reset_url}', + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False + ) + return Response({"message": "Password reset link sent"}) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + except Exception as e: + logger.error(f"Forgot pw error: {e}") + return Response({"error": "Failed to send email"}, status=500) + + +@api_view(['POST']) +def validate_reset_token(request): + token = request.data.get("token") + try: + rt = PasswordResetToken.objects.get(token=token) + if rt.is_valid(): + return Response({"valid": True}) + return Response({"valid": False, "error": "Token expired or used"}, status=400) + except PasswordResetToken.DoesNotExist: + return Response({"valid": False, "error": "Invalid token"}, status=400) + + +@never_cache +def reset_password_page(request): + if request.user.is_authenticated: + return redirect('dashboard') + token = request.GET.get('token') + return render(request, "users/reset_password.html", {'token': token}) + -@login_required(login_url='mlogin') +@api_view(['POST']) +def reset_password(request): + token = request.data.get("token") + new_pass = request.data.get("new_password") + try: + rt = PasswordResetToken.objects.get(token=token) + if not rt.is_valid(): + return Response({"error": "Invalid or expired token"}, status=400) + user = rt.user + user.set_password(new_pass) + user.save() + rt.is_used = True + rt.save() + return Response({"message": "Password reset successfully"}) + except PasswordResetToken.DoesNotExist: + return Response({"error": "Invalid token"}, status=400) + except Exception as e: + logger.error(f"Reset pw error: {e}") + return Response({"error": "Failed to reset password"}, status=500) + + +@login_required(login_url='login') def profile(request): - """ - Show and update user profile. - """ user = request.user patient_data = Patient.objects.filter(username=user.username).first() - user_data = User.objects.filter(username=user.username).first() - + user_data = User.objects.filter(username=user.username).first() if not patient_data: return HttpResponse("Patient data not found", status=404) form = CustomUserUpdateForm(initial={ - "firstname": patient_data.first_name, - "lastname": patient_data.last_name, - "email": patient_data.email, - "phone": patient_data.phone_number, + "firstname": patient_data.first_name, + "lastname": patient_data.last_name, + "email": patient_data.email, + "phone": patient_data.phone_number, "birth_date": patient_data.date_of_birth }) - if request.method == 'POST': form = CustomUserUpdateForm(request.POST) if form.is_valid(): - patient_data.first_name = form.cleaned_data.get("first_name", patient_data.first_name) - patient_data.last_name = form.cleaned_data.get("last_name", patient_data.last_name) - patient_data.email = form.cleaned_data.get("email", patient_data.email) - patient_data.phone_number = form.cleaned_data.get("phone", patient_data.phone_number) - patient_data.date_of_birth = form.cleaned_data.get("birth_date", patient_data.date_of_birth) - - user_data.first_name = form.cleaned_data.get("first_name", user_data.first_name) - user_data.last_name = form.cleaned_data.get("last_name", user_data.last_name) - user_data.email = form.cleaned_data.get("email", user_data.email) - + patient_data.first_name = form.cleaned_data["first_name"] + patient_data.last_name = form.cleaned_data["last_name"] + patient_data.email = form.cleaned_data["email"] + patient_data.phone_number = form.cleaned_data["phone"] + patient_data.date_of_birth = form.cleaned_data["birth_date"] + user_data.first_name = form.cleaned_data["first_name"] + user_data.last_name = form.cleaned_data["last_name"] + user_data.email = form.cleaned_data["email"] patient_data.save() user_data.save() return redirect("profile") - else: - return render(request, 'users/profile.html', context={"form": form}) + return render(request, 'users/profile.html', {"form": form}) + -@login_required(login_url='mlogin') +@login_required(login_url='login') def cancel_appointment(request, appointment_id): - appointment = get_object_or_404(Appointment, id=appointment_id, user=request.user) + appt = get_object_or_404(Appointment, id=appointment_id, user=request.user) if request.method == "POST": - appointment.delete() - messages.success(request, "Appointment canceled successfully.") - return redirect("dashboard") - + appt.delete() + return redirect("dashboard") + return HttpResponse("Method not allowed", status=405) + + +@never_cache def privacy_policy(request): return render(request, 'users/privacy_policy.html') -@require_GET + +@api_view(['GET']) def search_records(request): - """ - @ai-generated - Tool: ChatGPT (OpenAI) - Prompt: "Write a Django search API view that filters JSON medical records by doctor or prescription for a specific user" - Generated on: 2025-06-07 - Modified by: Mengliang Tan - Modifications: Add fake id for testing - Reason for using JSON: - The team has not yet set up a real database, so this view uses a local `records.json` file as a temporary data source. This allows - frontend development and testing to proceed while backend models and database configurations are still in progress. - - Verified: βœ… Tested via frontend - """ query = request.GET.get("q", "").lower() - user_id = 1 # fake id for testing - + user_id = 1 with open("data_mockup/records.json") as f: records = json.load(f) - matched = [ - {key: r[key] for key in r if key != "user_id"} + {k: r[k] for k in r if k != "user_id"} for r in records if r["user_id"] == user_id and ( - query in r["prescription"].lower() or query in r["doctor"].lower() + query in r["prescription"].lower() or + query in r["doctor"].lower() ) ] - return JsonResponse(matched, safe=False) -@login_required(login_url='mlogin') + +@login_required(login_url='login') def medical_records(request): - """ - Render the static medical records page for patients. - """ return render(request, 'users/medical_records.html') diff --git a/code/nginx.conf b/code/nginx.conf new file mode 100644 index 00000000..bbc1c647 --- /dev/null +++ b/code/nginx.conf @@ -0,0 +1,33 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + + server { + listen 80; + server_name localhost; + + # Static files + location /static/ { + alias /usr/share/nginx/html/static/; + add_header Cache-Control "no-cache"; + } + # Media uploads + location /media/ { + alias /usr/share/nginx/html/media/; + } + + # Proxy everything else to Django + location / { + proxy_pass http://mymedic:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/code/requirements.txt b/code/requirements.txt index 85c2c576..bf62f5a3 100644 --- a/code/requirements.txt +++ b/code/requirements.txt @@ -6,4 +6,5 @@ pytest-django==4.10.0 pytest==8.3.5 djangorestframework==3.16.0 djangorestframework-simplejwt==5.5.0 -django-cors-headers==4.7.0 \ No newline at end of file +django-cors-headers==4.7.0 +dotenv==0.9.9 \ No newline at end of file From ceb198d19166675453ad04ed1020dced4e2058cf Mon Sep 17 00:00:00 2001 From: Indra Sigicharla Date: Mon, 16 Jun 2025 16:01:53 -0400 Subject: [PATCH 2/4] Minor fixes and improved documentation --- code/Readme.md | 20 +++++++++++++++++-- code/docker-cmd.sh | 0 code/docker-entrypoint.sh | 0 code/mymedic/static/css/base.css | 8 ++++++++ code/mymedic/static/css/form.css | 6 +++--- .../users/templates/users/register.html | 16 +++++++-------- code/mymedic/users/urls.py | 10 +++++++++- 7 files changed, 46 insertions(+), 14 deletions(-) mode change 100644 => 100755 code/docker-cmd.sh mode change 100644 => 100755 code/docker-entrypoint.sh diff --git a/code/Readme.md b/code/Readme.md index b2c72ec9..5007b893 100644 --- a/code/Readme.md +++ b/code/Readme.md @@ -17,15 +17,31 @@ cp .env.example .env ``` Edit `.env` and add a DJANGO_SECRET_KEY generated from [Django Secret Key Generator](https://djecrety.ir/). +Add the email and app passwords for the Gmail account. --- ### 3. Run the Server +If Docker gives an error about permissions for executing docker-cmd or docker-entrypoint, run the following command to fix it: + +```bash +chmod +x docker-cmd.sh +chmod +x docker-entrypoint.sh +``` + #### Run in Production Mode ```bash -docker compose up --build +docker compose up --build --force-recreate +``` + +This binds the local website code into the container and serves it at `http://localhost:8080`. + +#### To remove old containers and images, run: + +```bash +docker compose down -v --remove-orphans ``` -This binds the local website code into the container and serves it at `http://localhost:8080`. \ No newline at end of file +--- \ No newline at end of file diff --git a/code/docker-cmd.sh b/code/docker-cmd.sh old mode 100644 new mode 100755 diff --git a/code/docker-entrypoint.sh b/code/docker-entrypoint.sh old mode 100644 new mode 100755 diff --git a/code/mymedic/static/css/base.css b/code/mymedic/static/css/base.css index d8a336f3..757caf49 100644 --- a/code/mymedic/static/css/base.css +++ b/code/mymedic/static/css/base.css @@ -103,4 +103,12 @@ a:hover { .display-6 { color: #20b2aa !important; +} + +.auth-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 2rem; } \ No newline at end of file diff --git a/code/mymedic/static/css/form.css b/code/mymedic/static/css/form.css index 84482ed5..5e873ca9 100644 --- a/code/mymedic/static/css/form.css +++ b/code/mymedic/static/css/form.css @@ -1,12 +1,12 @@ /* Forms */ .auth-box { background: white; - padding: 3.5rem; + padding: 2.5rem; border-radius: 21px; box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); - width: 532px; + max-width: 500px; + width: 100%; backdrop-filter: blur(10px); - margin: 10 auto; } .form-control { diff --git a/code/mymedic/users/templates/users/register.html b/code/mymedic/users/templates/users/register.html index 38bfc565..59df0a1d 100644 --- a/code/mymedic/users/templates/users/register.html +++ b/code/mymedic/users/templates/users/register.html @@ -7,8 +7,8 @@ - +
@@ -17,13 +17,13 @@

MyMedic

Register

- - - - - - - + + + + + + +

diff --git a/code/mymedic/users/urls.py b/code/mymedic/users/urls.py index 42092f7e..e2a25fa1 100644 --- a/code/mymedic/users/urls.py +++ b/code/mymedic/users/urls.py @@ -1,7 +1,15 @@ from django.urls import path +from django.contrib.auth.decorators import login_required from django.shortcuts import redirect +from django.http import HttpResponseRedirect from . import views +def root_redirect(request): + if request.user.is_authenticated: + return redirect('dashboard') + else: + return redirect('login') + urlpatterns = [ # User authentication routes path('login/', views.login_page, name='login'), @@ -35,5 +43,5 @@ path('privacy/', views.privacy_policy, name='privacy_policy'), # Default redirect - path('', lambda request: redirect('login')), + path('', root_redirect), ] From feabcdcff5a98b96230686a36334c18407611421 Mon Sep 17 00:00:00 2001 From: Indra Sigicharla Date: Mon, 16 Jun 2025 16:34:19 -0400 Subject: [PATCH 3/4] Minor CSS fixes --- .../users/templates/users/profile.html | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/code/mymedic/users/templates/users/profile.html b/code/mymedic/users/templates/users/profile.html index fe3819ae..96fecea0 100644 --- a/code/mymedic/users/templates/users/profile.html +++ b/code/mymedic/users/templates/users/profile.html @@ -1,4 +1,5 @@ {% load static %} + Profile @@ -27,31 +28,35 @@

MyMedic

+
-
-

MyMedic

-

Update Profile

-
- - {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - {% if form.errors %} -
-
    - {% for field in form %} - {% for error in field.errors %} -
  • {{ error|escape }}
  • - {% endfor %} - {% endfor %} - {% for error in form.non_field_errors %} -
  • {{ error|escape }}
  • - {% endfor %} -
-
- {% endif %} - - +
+

MyMedic

+

Update Profile

+
+
+ {% csrf_token %} + {% for field in form %} + {{ field }} + {% endfor %} + + {% if form.errors %} +
+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ error|escape }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error|escape }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+ \ No newline at end of file From 5c07d4a223364618c8482737f56f5a9ef10b9c89 Mon Sep 17 00:00:00 2001 From: Indra Sigicharla Date: Mon, 16 Jun 2025 17:04:31 -0400 Subject: [PATCH 4/4] Minor bug fixes --- code/mymedic/users/views.py | 60 +++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/code/mymedic/users/views.py b/code/mymedic/users/views.py index 31edfd4e..31fa942a 100644 --- a/code/mymedic/users/views.py +++ b/code/mymedic/users/views.py @@ -106,7 +106,7 @@ def register(request): try: dob_date = datetime.strptime(dob, '%Y-%m-%d').date() except ValueError: - logger.warning(f"Invalid dob: {dob}") + logger.warning("Invalid date of birth format provided") Patient.objects.create( username=user.username, first_name=first_name, @@ -129,24 +129,14 @@ def register(request): logger.error(f"Register error: {e}") return Response({"error": "Failed to create account"}, status=500) - @never_cache +@login_required(login_url='login') def dashboard(request): - token = request.COOKIES.get('access_token') or request.session.get('access_token') - if not token: - return redirect('login') - try: - jwt_auth = JWTAuthentication() - validated_token = jwt_auth.get_validated_token(token) - user = jwt_auth.get_user(validated_token) - appointments = Appointment.objects.filter(user=user).order_by('date') - return render(request, "users/dashboard.html", { - 'user': user, - 'appointments': appointments - }) - except (InvalidToken, TokenError): - return redirect('login') - + appointments = Appointment.objects.filter(user=request.user).order_by('date') + return render(request, "users/dashboard.html", { + 'user': request.user, + 'appointments': appointments + }) @never_cache def forgot_password_page(request): @@ -225,33 +215,37 @@ def reset_password(request): def profile(request): user = request.user patient_data = Patient.objects.filter(username=user.username).first() - user_data = User.objects.filter(username=user.username).first() + user_data = User.objects.filter(username=user.username).first() + if not patient_data: return HttpResponse("Patient data not found", status=404) - form = CustomUserUpdateForm(initial={ - "firstname": patient_data.first_name, - "lastname": patient_data.last_name, - "email": patient_data.email, - "phone": patient_data.phone_number, - "birth_date": patient_data.date_of_birth - }) if request.method == 'POST': form = CustomUserUpdateForm(request.POST) if form.is_valid(): - patient_data.first_name = form.cleaned_data["first_name"] - patient_data.last_name = form.cleaned_data["last_name"] - patient_data.email = form.cleaned_data["email"] - patient_data.phone_number = form.cleaned_data["phone"] + patient_data.first_name = form.cleaned_data["first_name"] + patient_data.last_name = form.cleaned_data["last_name"] + patient_data.email = form.cleaned_data["email"] + patient_data.phone_number = form.cleaned_data["phone"] patient_data.date_of_birth = form.cleaned_data["birth_date"] - user_data.first_name = form.cleaned_data["first_name"] - user_data.last_name = form.cleaned_data["last_name"] - user_data.email = form.cleaned_data["email"] + + user_data.first_name = form.cleaned_data["first_name"] + user_data.last_name = form.cleaned_data["last_name"] + user_data.email = form.cleaned_data["email"] + patient_data.save() user_data.save() return redirect("profile") - return render(request, 'users/profile.html', {"form": form}) + else: + form = CustomUserUpdateForm(initial={ + "first_name": patient_data.first_name, + "last_name": patient_data.last_name, + "email": patient_data.email, + "phone": patient_data.phone_number, + "birth_date": patient_data.date_of_birth + }) + return render(request, 'users/profile.html', {"form": form}) @login_required(login_url='login') def cancel_appointment(request, appointment_id):