diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..603f0b6 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,19 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e0fe99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Django # +*.log +*.pot +*.pyc +__pycache__ +db.sqlite3 +media + +# Backup files # +*.bak + +# If you are using PyCharm # +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Python # +*.py[cod] +*$py.class + +# Distribution / packaging +.Python build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Sublime Text # +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files Package +Control.last-run +Control.ca-list +Control.ca-bundle +Control.system-ca-bundle +GitHub.sublime-settings + +# Visual Studio Code # +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +# Python package installation files # +*.whl + +firebase_admin_sdk.json +django_secret_key.txt \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/AdvancedDevelopment.iml b/.idea/AdvancedDevelopment.iml new file mode 100644 index 0000000..92e2238 --- /dev/null +++ b/.idea/AdvancedDevelopment.iml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..feb03d1 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..c57f64e --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..aad5bda --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b4b7375 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AdvancedDevelopment/__init__.py b/AdvancedDevelopment/__init__.py new file mode 100644 index 0000000..a7f7e3c --- /dev/null +++ b/AdvancedDevelopment/__init__.py @@ -0,0 +1,15 @@ +import pymongo +from pymongo import MongoClient +from pymongo.errors import BulkWriteError + +# cluster = MongoClient("mongodb+srv://s5124723:UJZ6xxtNrnYwSNWowyz8@advanceddevelopment.aiiro.mongodb.net/test") +# db = cluster["AdvancedDevelopment"] +# collection = db["test"] + +# try: +# post1 = {"_id": 1, "name": "Lucas", "score": 5} +# post2 = {"_id": 2, "name": "Daniel", "score": 5} +# collection.insert_many([post1, post2]) +# except BulkWriteError as bwe: +# print(bwe) +# diff --git a/AdvancedDevelopment/asgi.py b/AdvancedDevelopment/asgi.py new file mode 100644 index 0000000..a67586d --- /dev/null +++ b/AdvancedDevelopment/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for AdvancedDevelopment 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/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'AdvancedDevelopment.settings') + +application = get_asgi_application() diff --git a/AdvancedDevelopment/firebase.py b/AdvancedDevelopment/firebase.py new file mode 100644 index 0000000..f6448ec --- /dev/null +++ b/AdvancedDevelopment/firebase.py @@ -0,0 +1,55 @@ +""" +Firebase admin interface used for connecting to and amending +Firestore database +""" + +import firebase_admin +from firebase_admin import credentials +from firebase_admin import firestore + +# Use the application default credentials +cred = credentials.ApplicationDefault() +firebase_admin.initialize_app(cred, { + 'projectId': 'idyllic-kit-328813', +}) + + +class FirebaseClient: + """Source: https://github.com/saadmk11/django-todo""" + + def __init__(self, collection): + self._db = firestore.client() + self._collection = self._db.collection(str(collection)) + + def create(self, data, document: str = None): + """Create item in firestore database""" + doc_ref = self._collection.document(document) + doc_ref.set(data) + + def update(self, document, data): + """Update item on firestore database using document id""" + doc_ref = self._collection.document(document) + doc_ref.update(data) + + def delete_by_id(self, document): + """Delete item on firestore database using document id""" + self._collection.document(document).delete() + + def get_by_id(self, document): + """Get item on firestore database using document id""" + doc_ref = self._collection.document(document) + doc = doc_ref.get() + + if doc.exists: + return {**doc.to_dict(), "id": doc.id} + return False + + def all(self): + """Get all item from firestore database""" + docs = self._collection.stream() + return [{**doc.to_dict(), "id": doc.id} for doc in docs] + + def filter(self, field, condition, value): + """Filter item using conditions on firestore database""" + docs = self._collection.where(field, condition, value).stream() + return [{**doc.to_dict(), "id": doc.id} for doc in docs] diff --git a/AdvancedDevelopment/main.py b/AdvancedDevelopment/main.py new file mode 100644 index 0000000..fc2036b --- /dev/null +++ b/AdvancedDevelopment/main.py @@ -0,0 +1,10 @@ +from AdvancedDevelopment.wsgi import application as app + +# App Engine by default looks for a main.py file at the root of the app +# directory with a WSGI-compatible object called app. +# This file imports the WSGI-compatible object of your Django app, +# application from mysite/wsgi.py and renames it app so it is discoverable by +# App Engine without additional configuration. +# Alternatively, you can add a custom entrypoint field in your app.yaml: +# entrypoint: gunicorn -b :$PORT mysite.wsgi +# https://stackoverflow.com/questions/52395695/modulenotfounderror-no-module-named-main-when-attempting-to-start-service diff --git a/AdvancedDevelopment/secrets.py b/AdvancedDevelopment/secrets.py new file mode 100644 index 0000000..5855189 --- /dev/null +++ b/AdvancedDevelopment/secrets.py @@ -0,0 +1,11 @@ +from google.api_core.exceptions import PermissionDenied +from google.cloud import secretmanager + + +def get_secret(name: str, fallback, client=None): + if not client: + client = secretmanager.SecretManagerServiceClient() + try: + return client.access_secret_version(name=name) + except PermissionDenied as error: + return fallback() diff --git a/AdvancedDevelopment/settings.py b/AdvancedDevelopment/settings.py new file mode 100644 index 0000000..de3dc3a --- /dev/null +++ b/AdvancedDevelopment/settings.py @@ -0,0 +1,161 @@ +""" +Django settings for AdvancedDevelopment project. + +Generated by 'django-admin startproject' using Django 3.2.9. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" +import os +from pathlib import Path +import firebase_admin +from firebase_admin import credentials +import djongo +import requests +from google.api_core.exceptions import PermissionDenied +from google.auth.exceptions import DefaultCredentialsError +from google.cloud import secretmanager + +from AdvancedDevelopment.secrets import get_secret + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# Specify your Google API key as environment variable GOOGLE_API_KEY +GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY') +GOOGLE_APPLICATION_CREDENTIALS = os.path.join(BASE_DIR, "firebase_admin_sdk.json") +os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', + GOOGLE_APPLICATION_CREDENTIALS) + +# SECURITY WARNING: keep the secret key used in production secret! +try: + secrets = secretmanager.SecretManagerServiceClient() + SECRET_KEY = get_secret("projects/idyllic-kit-328813/secrets/django_secret_key/versions/latest", + lambda: open(os.path.join(BASE_DIR, "django_secret_key.txt")).read()) +except FileNotFoundError: + pass + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "idyllic-kit-328813.ew.r.appspot.com"] + +# Application definition + +INSTALLED_APPS = [ + 'address', + 'products.apps.ParcelConfig', + 'users.apps.UsersConfig', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'crispy_forms' +] + +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 = 'AdvancedDevelopment.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR + 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' + +WSGI_APPLICATION = 'AdvancedDevelopment.wsgi.application' + +# Use Bootstrap 4 Forms With Django +# https://simpleisbetterthancomplex.com/tutorial/2018/08/13/how-to-use-bootstrap-4-forms-with-django.html + +CRISPY_TEMPLATE_PACK = 'bootstrap4' + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + +} + +# Password validation +# https://docs.djangoproject.com/en/3.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', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = '/static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Certifying Firebase credentials for Firebase integration +# https://jrizmal.medium.com/how-to-authenticate-firebase-users-in-django-rest-framework-c2d90f5a0a11 + +# REST_FRAMEWORK = { +# 'DEFAULT_AUTHENTICATION_CLASSES': [ +# 'FirebaseAuth.authentication.FirebaseAuthentication', +# ] +# } + +# try: +# # todo save response to file +# response = secrets.access_secret_version( +# name="projects/idyllic-kit-328813/secrets/Firebase_Admin_SDK/versions/latest") +# cred = credentials.Certificate(response) +# except PermissionDenied as exception: # todo write to log +# cred = credentials.Certificate(os.path.join(BASE_DIR, "firebase_admin_sdk.json")) +# +# firebase_admin.initialize_app(cred) diff --git a/AdvancedDevelopment/urls.py b/AdvancedDevelopment/urls.py new file mode 100644 index 0000000..486264c --- /dev/null +++ b/AdvancedDevelopment/urls.py @@ -0,0 +1,84 @@ +"""AdvancedDevelopment URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.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.contrib.auth import views as auth_views +from django.urls import path +from products import views as product_views +from users import views as user_views + +urlpatterns = [ + path("", product_views.home, name="home"), + # path("", user_views.register, name="home"), + path("admin/", admin.site.urls), + path("product/new/", product_views.ProductCreateView.as_view(), name="product-create"), + path("product//", product_views.product_detail_view, name="product"), + path("order//", product_views.order, name="order"), + path("my-orders/", product_views.my_orders, name="my-orders"), + path("cancel-order//", product_views.cancel_order, name="cancel-order"), + path("progress-order//", product_views.progress_order, name="progress-order"), + # path("order/new/", product_views.OrderCreateView.as_view(), name="order-create"), + path("register/", user_views.register, name="register"), + # path("profile/deactivate", DeactivateUser.as_view(), name="delete-profile"), + # path( + # "login/", + # auth_views.LoginView.as_view(template_name="users/login.html"), + # name="login", + # ), + path( + "login/", + user_views.login, + name="login", + ), + path( + "logout/", + user_views.logout, + name="logout", + ), + path( + "change-password/", + auth_views.PasswordChangeView.as_view( + template_name="users/password_change.html", + success_url="/" + ), + name="password_change" + ), + path( + "password-reset/", + auth_views.PasswordResetView.as_view(template_name="users/password_reset.html"), + name="password_reset", + ), + path( + "password-reset/done", + auth_views.PasswordResetDoneView.as_view( + template_name="users/password_reset_done.html" + ), + name="password_reset_done", + ), + path( + "password-reset-confirm//", + auth_views.PasswordResetConfirmView.as_view( + template_name="users/password_reset_confirm.html" + ), + name="password_reset_confirm", + ), + path( + "password-reset-complete/", + auth_views.PasswordResetCompleteView.as_view( + template_name="users/password_reset_complete.html" + ), + name="password_reset_complete", + ) +] diff --git a/AdvancedDevelopment/utils/__init__.py b/AdvancedDevelopment/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/AdvancedDevelopment/utils/access_secret_version.py b/AdvancedDevelopment/utils/access_secret_version.py new file mode 100644 index 0000000..95b12ae --- /dev/null +++ b/AdvancedDevelopment/utils/access_secret_version.py @@ -0,0 +1,19 @@ +def access_secret_version(project_id, secret_id, version_id): + """ + Access the payload for the given secret version if one exists. The version + can be a version number as a string (e.g. "5") or an alias (e.g. "latest"). + """ + + # Import the Secret Manager client library. + from google.cloud import secretmanager + + # Create the Secret Manager client. + client = secretmanager.SecretManagerServiceClient() + + # Build the resource name of the secret version. + name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" + + # Access the secret version. + response = client.access_secret_version(request={"name": name}) + return response + \ No newline at end of file diff --git a/AdvancedDevelopment/wsgi.py b/AdvancedDevelopment/wsgi.py new file mode 100644 index 0000000..4616fae --- /dev/null +++ b/AdvancedDevelopment/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for AdvancedDevelopment 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/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'AdvancedDevelopment.settings') + +application = get_wsgi_application() diff --git a/app.yaml b/app.yaml new file mode 100644 index 0000000..e500e18 --- /dev/null +++ b/app.yaml @@ -0,0 +1,2 @@ +runtime: python39 +entrypoint: gunicorn -b :$PORT AdvancedDevelopment.wsgi \ No newline at end of file diff --git a/default.jpg b/default.jpg new file mode 100644 index 0000000..622c158 Binary files /dev/null and b/default.jpg differ diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..08898b4 --- /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', 'AdvancedDevelopment.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/products/__init__.py b/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/products/admin.py b/products/admin.py new file mode 100644 index 0000000..8aec5b4 --- /dev/null +++ b/products/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from products.models import Product, Order + +admin.site.register(Product) +admin.site.register(Order) diff --git a/products/apps.py b/products/apps.py new file mode 100644 index 0000000..0c61eb8 --- /dev/null +++ b/products/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ParcelConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'products' diff --git a/products/forms.py b/products/forms.py new file mode 100644 index 0000000..58c2b25 --- /dev/null +++ b/products/forms.py @@ -0,0 +1,7 @@ +from django import forms + + +class OrderForm(forms.Form): + customer = forms.EmailField() + product_id = forms.CharField(max_length=16) + diff --git a/products/migrations/0001_initial.py b/products/migrations/0001_initial.py new file mode 100644 index 0000000..202cbbc --- /dev/null +++ b/products/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.9 on 2021-12-20 23:54 + +import address.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('address', '0004_auto_20211215_0401'), + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('desc', models.TextField(max_length=512)), + ('price', models.FloatField()), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField()), + ('status', models.CharField(choices=[('1', 'Order confirmed'), ('2', 'Awaiting dispatch'), ('3', 'Dispatched, awaiting courier'), ('4', 'With courier, awaiting delivery'), ('5', 'Out for delivery'), ('6', 'Order delivered and fulfilled')], default='1', max_length=1)), + ('address', address.models.AddressField(on_delete=django.db.models.deletion.CASCADE, to='address.address')), + ], + ), + ] diff --git a/products/migrations/0002_order_product.py b/products/migrations/0002_order_product.py new file mode 100644 index 0000000..d8daa72 --- /dev/null +++ b/products/migrations/0002_order_product.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.9 on 2021-12-21 02:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='product', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='products.product'), + preserve_default=False, + ), + ] diff --git a/products/migrations/__init__.py b/products/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/products/models.py b/products/models.py new file mode 100644 index 0000000..310b70b --- /dev/null +++ b/products/models.py @@ -0,0 +1,49 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.urls import reverse +from address.models import AddressField # django-address +from AdvancedDevelopment.firebase import FirebaseClient +from .utils import generate_id + + +class Product(models.Model): + id = models.CharField(max_length=16, primary_key=True) + name = models.CharField(max_length=128) + desc = models.TextField(max_length=512) + # img # todo add through google cloud storage + # price = models.FloatField() + + # def get_absolute_url(self): + # # todo issue: id is not set until after model is saved + # # Reverse for 'product' with keyword arguments '{'pk': ''}' not found. + # # 1 pattern(s) tried: ['product/(?P[^/]+)/$'] + # return reverse("product", kwargs={"pk": self.id}) + + def save(self, *args, **kwargs): + client = FirebaseClient("products") + id_gen = generate_id(client) + store_data = {"id": id_gen, + "name": self.name, + "desc": self.desc} + client.create(document=str(id_gen), data=store_data) + + +class Order(models.Model): + STATUS_CHOICES = [ + ("1", "Order confirmed"), + ("2", "Awaiting dispatch"), + ("3", "Dispatched, awaiting courier"), + ("4", "With courier, awaiting delivery"), + ("5", "Out for delivery"), + ("6", "Order delivered and fulfilled") + ] + + product = models.ForeignKey(Product, on_delete=models.CASCADE) + quantity = models.IntegerField() + address = AddressField() + status = models.CharField(default="1", max_length=1, choices=STATUS_CHOICES) + customer = get_user_model() + + def get_absolute_url(self): + return reverse("order", kwargs={"pk": self.pk}) + diff --git a/products/static/css/styling.css b/products/static/css/styling.css new file mode 100644 index 0000000..e69de29 diff --git a/products/templates/products/base.html b/products/templates/products/base.html new file mode 100644 index 0000000..115471e --- /dev/null +++ b/products/templates/products/base.html @@ -0,0 +1,76 @@ +{% load static %} + + + + + + {% if title %} + {{ title }} + {% else %} + Advanced Development + {% endif %} + + {# scripts and styles #} + + + + + + + + + + +
+ + + + +
+ +{% block banner %}{% endblock %} + +
+ {% if messages %} +
    + {% for message in messages %} +
  • + {{ message }} +
  • + {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} + +
+ + + \ No newline at end of file diff --git a/products/templates/products/home.html b/products/templates/products/home.html new file mode 100644 index 0000000..76ce78b --- /dev/null +++ b/products/templates/products/home.html @@ -0,0 +1,22 @@ +{% extends "products/base.html" %} +{% block banner %} +
+

Welcome to Product Co.

+

Submission for Advanced Development.

+

+ Register +

+
+{% endblock %} + +{% block content %} + {% for product in products %} +
+

{{ product.name }}

+

{{ product.desc }}

+ {% if logged_in %} + Order + {% endif %} +
+ {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/products/templates/products/my_orders.html b/products/templates/products/my_orders.html new file mode 100644 index 0000000..54c55d8 --- /dev/null +++ b/products/templates/products/my_orders.html @@ -0,0 +1,24 @@ +{% extends "products/base.html" %} +{% block content %} + + + + + + + + + + + {% for order in my_orders %} + + + + + + + {% endfor %} + +
#ProductStatusControls
{{ order.id }}{{ order.product_name }}{{ order.status }}Cancel + +1
+{% endblock %} \ No newline at end of file diff --git a/products/templates/products/order_detail.html b/products/templates/products/order_detail.html new file mode 100644 index 0000000..fdca0a4 --- /dev/null +++ b/products/templates/products/order_detail.html @@ -0,0 +1,12 @@ +{% extends "products/base.html" %} +{% block content %} +

Order #{{ order.id }} · {{ order.shipping.status }}

+

Shipping details

+

{{ order.shipping.status }}

+

{{ order.shipping.address }}

+
    + {% for product in parcel %} +
  • {{ product.name }} · £{{ product.price }}
  • + {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/products/templates/products/order_form.html b/products/templates/products/order_form.html new file mode 100644 index 0000000..d183e13 --- /dev/null +++ b/products/templates/products/order_form.html @@ -0,0 +1,7 @@ +{% extends "products/base.html" %} +{% block content %} +
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/products/templates/products/product_detail.html b/products/templates/products/product_detail.html new file mode 100644 index 0000000..f0e0ae1 --- /dev/null +++ b/products/templates/products/product_detail.html @@ -0,0 +1,8 @@ +{% extends "products/base.html" %} +{% block content %} +

{{ product.name }}

+

{{ product.desc }}

+

+ Order now +

+{% endblock %} \ No newline at end of file diff --git a/products/templates/products/product_form.html b/products/templates/products/product_form.html new file mode 100644 index 0000000..d183e13 --- /dev/null +++ b/products/templates/products/product_form.html @@ -0,0 +1,7 @@ +{% extends "products/base.html" %} +{% block content %} +
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/products/tests.py b/products/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/products/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/products/utils.py b/products/utils.py new file mode 100644 index 0000000..f8df90e --- /dev/null +++ b/products/utils.py @@ -0,0 +1,34 @@ +import random +import string + +from AdvancedDevelopment.firebase import FirebaseClient + + +def gen_ran(size=16): + """e.g. mqPssj5bbYDOSoy5""" + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(size)]) + + +def generate_id(client: FirebaseClient): + """Generate ID using gen_ran and rotate it until it's unique in the client""" + id_gen = gen_ran() + while client.get_by_id(id_gen) is not False: # if id is taken, find another until one is free + id_gen = gen_ran() + + return id_gen + + +def read_login_session(request): + try: + return request.session["login"] + except KeyError: + return "" + + +def logged_in(session_ref): + login = False + if session_ref: + get_user = FirebaseClient("users").get_by_id(session_ref) + if get_user: # if user is logged in + login = True + return login diff --git a/products/views.py b/products/views.py new file mode 100644 index 0000000..f0c8fa3 --- /dev/null +++ b/products/views.py @@ -0,0 +1,154 @@ +from django.contrib import messages +from django.http import HttpResponseNotFound +from django.shortcuts import render, redirect +from django.views.generic import CreateView + +import users.views +from AdvancedDevelopment.firebase import FirebaseClient +from products.models import Product +from .utils import generate_id, read_login_session, logged_in + +client_products = FirebaseClient("products") + +ORDER_STATUS_TEXT = [ + ("1", "Order pending"), + ("2", "Order confirmed, awaiting dispatch"), + ("3", "Dispatched, awaiting courier"), + ("4", "With courier, awaiting delivery"), + ("5", "Out for delivery"), + ("6", "Order delivered and fulfilled") +] + + +def home(request): + """Renders homepage. Context: all products (obj) and if user logged-in (bool)""" + # session = request.session["login"] + session = read_login_session(request) + auth = logged_in(session) + + all_products = client_products.all() + return render(request, "products/home.html", {"products": all_products, + "logged_in": auth}) + + +class ProductCreateView(CreateView): + model = Product + fields = ["name", "desc"] + + +def product_detail_view(request, pk): + product_doc = client_products.get_by_id(pk) + + if product_doc is not False: + return render(request, "products/product_detail.html", {"product": product_doc}) + else: + return HttpResponseNotFound('

Page not found

') + + +def progress_order(request, pk): + """ + Progresses an order's status, e.g. dispatch to delivery + :param request + :param pk: Order ID + :return: Redirects to login page if not logged in, otherwise to My Orders + """ + session = read_login_session(request) + auth = logged_in(session) + if not auth: # redirect non-logged-in to login page + return redirect(users.views.login) + + client_orders = FirebaseClient("orders") + order_ref = client_orders.get_by_id(pk) + + status = int(order_ref["status"]) + 1 + new_data = order_ref + new_data["status"] = status + client_orders.update(pk, new_data) + messages.success(request, "Order progressed to: " + str(status)) + return redirect(my_orders) + + +def my_orders(request): + """ + My Orders page for logged in user, containing all pending orders and their status + :param request + :return: Redirects to login if user not logged in, to my orders if logged in + """ + session = read_login_session(request) + auth = logged_in(session) + get_user = FirebaseClient("users").get_by_id(session) + if not auth: + return redirect(users.views.login) + else: + all_my_orders = FirebaseClient("orders").filter("uid", "==", get_user["username"]) # find all orders for user + all_products = FirebaseClient("products").all() + admin = False + if get_user["username"] == "admin": + admin = True + for _order in all_my_orders: + try: + _order["status"] = ORDER_STATUS_TEXT[_order["status"]][1] # change status enum to text + except IndexError: + _order["status"] = ORDER_STATUS_TEXT[5][1] + context = {"my_orders": all_my_orders, + "products": all_products, + "admin": admin} + return render(request, "products/my_orders.html", context) + + +# def order_detail_view(request, pk): +# order = FirebaseClient("orders").get_by_id(pk) +# return render(request, "products/order_detail.html") + + +def cancel_order(request, pk): + """ + Cancels an order and removes it from the db, requires logged-in user + :param request: + :param pk: Order ID + :return: Redirects to home if not logged-in, to my orders if logged in + """ + client = FirebaseClient("orders") + order = client.get_by_id(pk) + session = read_login_session(request) + auth = logged_in(session) + + if not auth: # if not logged-in, redirect to login + return redirect(users.views.login) + else: + get_user = FirebaseClient("users").get_by_id(session) + if order["uid"] != get_user["username"]: # checks if order matches to logged-in user + return redirect(home) + else: # delete order + client.delete_by_id(pk) # delete order from db + messages.success(request, "Order successfully cancelled") + return redirect(my_orders) + + +def order(request, pk): + """ + Place order, add to db, requires user logged-in + :param request: + :param pk: Product ID + :return: + """ + session = read_login_session(request) + auth = logged_in(session) + + if auth: # if user is logged-in + user_ref = FirebaseClient("users").get_by_id(session) # get user info + product_doc = client_products.get_by_id(pk) # product + if not product_doc: # if product doesn't exist, redirect to home + return redirect(home) + client_orders = FirebaseClient("orders") + gen_id = generate_id(client_orders) # generate order id + address = f"{user_ref['address1']} {user_ref['address2']} {user_ref['address3']} {user_ref['post_code']}" + data = {"uid": user_ref["id"], "id": gen_id, + "status": 0, "address": address, + "product_name": client_products.get_by_id(pk)["name"]} + client_orders.create(document=gen_id, data=data) # create order in db + messages.success(request, f"{product_doc['name']} successfully ordered!") + return redirect(my_orders) + else: + session = request.session["login"] + raise Exception("User not found") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e818425 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +asgiref==3.4.1 +Django==3.2.9 +dnspython==2.1.0 +pymongo==3.12.1 +pytz==2021.3 +djangorestframework~=3.12.4 +Pillow~=8.4.0 +django-crispy-forms==1.13.0 +django-address==0.2.5 +djongo~=1.3.6 +sqlparse>=0.2.4 +gunicorn>=20.1.0 +firebase_admin>=5.1.0 +google-cloud-secret-manager>=2.8.0 \ No newline at end of file diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..dd57fa2 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import Profile + + +class ProfileAdminConf(admin.ModelAdmin): + list_display = ["user"] + + +admin.site.register(Profile, ProfileAdminConf) diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..1322ac9 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = "users" + + def ready(self): + import users.signals diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..62e0c3b --- /dev/null +++ b/users/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.contrib.auth import password_validation +from django.contrib.auth.models import User + +from .models import Profile + + +class CustomLoginForm(forms.Form): + email = forms.EmailField() + password1 = forms.CharField( + label="Password", + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + ) + + class Meta: + fields = ["email", "password1"] + + +class UserRegisterForm(forms.Form): + """ + Registers the user through django and firebase + """ + username = forms.CharField(max_length=64) + email = forms.EmailField() + password1 = forms.CharField( + label="Password", + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + help_text=password_validation.password_validators_help_text_html(), + ) + password2 = forms.CharField( + label="Password confirmation", + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + strip=False, + help_text="Enter the same password as before, for verification.", + ) + address1 = forms.CharField(max_length=128, label="Address line 1") + address2 = forms.CharField(max_length=128, required=False, label="Address line 2") + address3 = forms.CharField(max_length=128, required=False, label="Address line 3") + post_code = forms.CharField(max_length=128, label="Postal code") + + def save(self): + pass + + class Meta: + fields = ["username", "email", "password1", "password2"] + + +class UserUpdateForm(forms.ModelForm): + email = forms.EmailField() + + class Meta: + model = User + fields = ["username", "email"] + + +class ProfileUpdateForm(forms.ModelForm): + class Meta: + model = Profile + fields = ["avatar"] diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..7b4d633 --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.9 on 2021-12-20 23:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('avatar', models.ImageField(default='default.jpg', upload_to='profile_pics')), + ('karma', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..fd80ae5 --- /dev/null +++ b/users/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.contrib.auth.models import User +from PIL import Image + + +class Profile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + avatar = models.ImageField(default="default.jpg", upload_to="profile_pics") + karma = models.IntegerField(default=0) + + # def count_strikes(self): + # """ + # Returns + # :return: + # """ + # issued = Strike.objects.filter(user=self.user, strike_type="A").count() + # redacted = Strike.objects.filter(user=self.user, strike_type="R").count() + # return issued - redacted + + def __str__(self): + """ + @return: Human readable name for the model + @rtype: string + """ + return f"{self.user.username}'s profile" + + def save(self, *args, **kwargs): + """ + This overrides the default save function for profile so it can resize image to + 300px after saving. It first uploads the user's image, resizes and saves + """ + super().save() + + img = Image.open(self.avatar.path) + + if img.height > 300 or img.width > 300: + output_size = (300, 300) + img.thumbnail(output_size) + img.save(self.avatar.path) diff --git a/users/signals.py b/users/signals.py new file mode 100644 index 0000000..299e44a --- /dev/null +++ b/users/signals.py @@ -0,0 +1,15 @@ +from django.db.models.signals import post_save +from django.contrib.auth.models import User +from django.dispatch import receiver +from .models import Profile + + +@receiver(post_save, sender=User) +def create_profile(sender, instance, created, **kwargs): + if created: + Profile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_profile(sender, instance, **kwargs): + instance.profile.save() diff --git a/users/templates/users/login.html b/users/templates/users/login.html new file mode 100644 index 0000000..53fec7b --- /dev/null +++ b/users/templates/users/login.html @@ -0,0 +1,27 @@ +{% extends "products/base.html" %} +{% block content %} +
+
+ {% csrf_token %} +
+ + Log In + + {{ form }} +
+
+ + + Forgot password? + +
+
+
+ + Need an account? Register instead. + +
+
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/logout.html b/users/templates/users/logout.html new file mode 100644 index 0000000..fe31020 --- /dev/null +++ b/users/templates/users/logout.html @@ -0,0 +1,9 @@ +{% extends "products/base.html" %} +{% block content %} +

You have been logged out

+
+ + Log in again. + +
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/password_change.html b/users/templates/users/password_change.html new file mode 100644 index 0000000..a88856d --- /dev/null +++ b/users/templates/users/password_change.html @@ -0,0 +1,20 @@ +{% extends "products/base.html" %} +{% load crispy_forms_tags %} +{% block content %} +
+
+ {% csrf_token %} +
+ + Change password + +
+ {{ form|crispy }} +
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/password_reset.html b/users/templates/users/password_reset.html new file mode 100644 index 0000000..9d0a670 --- /dev/null +++ b/users/templates/users/password_reset.html @@ -0,0 +1,19 @@ +{% extends "products/base.html" %} +{% block content %} +
+
+ {% csrf_token %} + {{ form }} +
+ + Reset password + +
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/password_reset_complete.html b/users/templates/users/password_reset_complete.html new file mode 100644 index 0000000..16c972a --- /dev/null +++ b/users/templates/users/password_reset_complete.html @@ -0,0 +1,7 @@ +{% extends "products/base.html" %} +{% block content %} +
+ Your password has been changed. +
+ Sign in again +{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/password_reset_confirm.html b/users/templates/users/password_reset_confirm.html new file mode 100644 index 0000000..9111c5e --- /dev/null +++ b/users/templates/users/password_reset_confirm.html @@ -0,0 +1,19 @@ +{% extends "products/base.html" %} +{% block content %} +
+
+ {% csrf_token %} +
+ + Reset password + + {{ form }} +
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/password_reset_done.html b/users/templates/users/password_reset_done.html new file mode 100644 index 0000000..47f94ce --- /dev/null +++ b/users/templates/users/password_reset_done.html @@ -0,0 +1,6 @@ +{% extends "products/base.html" %} +{% block content %} +
+ An email has been with instructions to reset your password +
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/profile.html b/users/templates/users/profile.html new file mode 100644 index 0000000..a5d0ffa --- /dev/null +++ b/users/templates/users/profile.html @@ -0,0 +1,45 @@ +{% extends "products/base.html" %} +{% block content %} +
+
+ +
+ +

{{ user.email }}

+
+
+ {% if ban %} +
+

Account suspended

+

{{ ban.get_ban_type_display|capfirst }} ban + issued {{ ban.issued_when|date:"jS F, Y" }}{% if ban.expires is None %}.{% else %},{% endif %} + {% if ban.expires %} + expires {{ ban.expires|date:"jS F, Y" }} at {{ ban.expires|date:"g:i a" }} + {% endif %} +

+ {% if ban.issued_message %} +

+ + {{ ban.issued_message }} + +

+ {% endif %} +
+ {% endif %} +
+ {% csrf_token %} +
+ + Change profile + + {{ u_form }} + {{ p_form }} +
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/users/templates/users/profile_deactivate_confirm.html b/users/templates/users/profile_deactivate_confirm.html new file mode 100644 index 0000000..e2788cb --- /dev/null +++ b/users/templates/users/profile_deactivate_confirm.html @@ -0,0 +1,11 @@ +{% extends "products/base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+ {% csrf_token %} + Are you sure you wish to deactivate your account? + + Cancel +
+{% endblock %} \ No newline at end of file diff --git a/users/templates/users/register.html b/users/templates/users/register.html new file mode 100644 index 0000000..b91ba94 --- /dev/null +++ b/users/templates/users/register.html @@ -0,0 +1,25 @@ +{% extends "products/base.html" %} +{% load crispy_forms_tags %} +{% block content %} +
+
+ {% csrf_token %} +
+ + Join Today + + {{ form|crispy }} +
+
+ +
+
+
+ + Already registered? Sign in instead. + +
+
+{% endblock content %} \ No newline at end of file diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..5b86db8 --- /dev/null +++ b/users/views.py @@ -0,0 +1,75 @@ +from django.contrib import messages +from django.shortcuts import render, redirect + +from AdvancedDevelopment.firebase import FirebaseClient +from .forms import UserRegisterForm, CustomLoginForm + + +def register(request): + """Registration page, any user can access""" + if request.method == "POST": + form = UserRegisterForm(request.POST) + if form.is_valid(): + data = form.cleaned_data + firebase = FirebaseClient("users") + firebase.create(document=str(data["username"]), data=data) + messages.success( + request, f"Successfully registered {data['username']}! You may now login" + ) + else: + form = UserRegisterForm() + return render(request, "users/register.html", {"form": form}) + + +def _login_validation(firebase_client: FirebaseClient, form: CustomLoginForm): + """ + Checks the entered login details against the database user records + :param firebase_client: Firebase client connecting to users collection + :param form: the custom login form containing login information + :return: if password entered matches stored password, the result of the query to find the user + """ + if form.is_valid(): + query = firebase_client.filter("email", "==", form.cleaned_data["email"]) + user_found = False + try: + user_found = query[0] + except IndexError: # Invalid login, user does not exist + return False, user_found + password_match = user_found["password2"] == form.cleaned_data["password1"] + return password_match, user_found + else: + return False, False + + +def login(request): + """Login page, any user can access but warning displays""" + try: + messages.warning(request, f"You are already logged in as, {request.session['login']}") + except KeyError: + pass + if request.method == "POST": + form = CustomLoginForm(request.POST) + if form.is_valid(): + client = FirebaseClient("users") + password_match, user_found = _login_validation(client, form) + if password_match: + request.session["login"] = user_found["username"] + messages.success(request, f"Successful login as, {form.cleaned_data['email']}") + else: + messages.error(request, "You have entered the wrong login credentials!") + else: + messages.error(request, "Failed to login!") + else: + form = CustomLoginForm() + return render(request, "users/login.html", {"form": form}) + + +def logout(request): + """Logs the user out and redirects them to login page""" + session: str + try: + request.session.pop("login") + messages.success(request, "You have been successfully logged out") + return redirect(login) + except KeyError: # user is not logged in, redirect to login page + return redirect(login)