From f3116a687662e1c6d02f1549af7432961f602d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Thu, 26 Sep 2024 23:59:03 +0200 Subject: [PATCH] First commit --- .gitignore | 182 +++++++++++++++++ k356/app/__init__.py | 0 k356/app/asgi.py | 16 ++ k356/app/settings.py | 154 ++++++++++++++ k356/app/urls.py | 28 +++ k356/app/utils/__init__.py | 0 k356/app/utils/api/__init__.py | 0 k356/app/utils/api/api_list.py | 21 ++ k356/app/utils/extra_context.py | 37 ++++ k356/app/utils/models.py | 62 ++++++ k356/app/wsgi.py | 16 ++ k356/items/__init__.py | 0 k356/items/admin.py | 32 +++ k356/items/apps.py | 6 + k356/items/migrations/0001_initial.py | 130 ++++++++++++ .../0002_item_custom_identifier_and_more.py | 43 ++++ k356/items/migrations/__init__.py | 0 k356/items/models.py | 88 ++++++++ .../templates/components/item/template.html | 5 + k356/items/templates/components/item/vue.js | 4 + .../components/item_list/template.html | 88 ++++++++ .../templates/components/item_list/vue.js | 191 ++++++++++++++++++ k356/items/tests.py | 3 + k356/items/urls.py | 10 + k356/items/views/__init__.py | 0 k356/items/views/item.py | 90 +++++++++ k356/main/__init__.py | 0 k356/main/admin.py | 3 + k356/main/apps.py | 6 + k356/main/migrations/__init__.py | 0 k356/main/models.py | 3 + .../encryption-testing/template.html | 21 ++ .../components/encryption-testing/vue.js | 34 ++++ .../components/k356-loading/template.html | 18 ++ .../templates/components/k356-loading/vue.js | 109 ++++++++++ k356/main/templates/main/home.html | 10 + k356/main/tests.py | 3 + k356/main/urls.py | 12 ++ k356/main/views.py | 7 + k356/manage.py | 22 ++ k356/static_source/img/favicon.png | Bin 0 -> 24259 bytes k356/templates/base.html | 163 +++++++++++++++ k356/templates/scripts.js | 56 +++++ k356/users/__init__.py | 0 k356/users/admin.py | 16 ++ k356/users/apps.py | 6 + k356/users/migrations/0001_initial.py | 34 ++++ .../0002_usersettings_custom_identifier.py | 18 ++ k356/users/migrations/__init__.py | 0 k356/users/models.py | 14 ++ k356/users/tests.py | 3 + k356/users/urls.py | 10 + k356/users/views.py | 48 +++++ 53 files changed, 1822 insertions(+) create mode 100644 .gitignore create mode 100644 k356/app/__init__.py create mode 100644 k356/app/asgi.py create mode 100644 k356/app/settings.py create mode 100644 k356/app/urls.py create mode 100644 k356/app/utils/__init__.py create mode 100644 k356/app/utils/api/__init__.py create mode 100644 k356/app/utils/api/api_list.py create mode 100644 k356/app/utils/extra_context.py create mode 100644 k356/app/utils/models.py create mode 100644 k356/app/wsgi.py create mode 100644 k356/items/__init__.py create mode 100644 k356/items/admin.py create mode 100644 k356/items/apps.py create mode 100644 k356/items/migrations/0001_initial.py create mode 100644 k356/items/migrations/0002_item_custom_identifier_and_more.py create mode 100644 k356/items/migrations/__init__.py create mode 100644 k356/items/models.py create mode 100644 k356/items/templates/components/item/template.html create mode 100644 k356/items/templates/components/item/vue.js create mode 100644 k356/items/templates/components/item_list/template.html create mode 100644 k356/items/templates/components/item_list/vue.js create mode 100644 k356/items/tests.py create mode 100644 k356/items/urls.py create mode 100644 k356/items/views/__init__.py create mode 100644 k356/items/views/item.py create mode 100644 k356/main/__init__.py create mode 100644 k356/main/admin.py create mode 100644 k356/main/apps.py create mode 100644 k356/main/migrations/__init__.py create mode 100644 k356/main/models.py create mode 100644 k356/main/templates/components/encryption-testing/template.html create mode 100644 k356/main/templates/components/encryption-testing/vue.js create mode 100644 k356/main/templates/components/k356-loading/template.html create mode 100644 k356/main/templates/components/k356-loading/vue.js create mode 100644 k356/main/templates/main/home.html create mode 100644 k356/main/tests.py create mode 100644 k356/main/urls.py create mode 100644 k356/main/views.py create mode 100755 k356/manage.py create mode 100644 k356/static_source/img/favicon.png create mode 100644 k356/templates/base.html create mode 100644 k356/templates/scripts.js create mode 100644 k356/users/__init__.py create mode 100644 k356/users/admin.py create mode 100644 k356/users/apps.py create mode 100644 k356/users/migrations/0001_initial.py create mode 100644 k356/users/migrations/0002_usersettings_custom_identifier.py create mode 100644 k356/users/migrations/__init__.py create mode 100644 k356/users/models.py create mode 100644 k356/users/tests.py create mode 100644 k356/users/urls.py create mode 100644 k356/users/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a11ed38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,182 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python + +cache +output +config.ini +settingsLocal.py +output.csv diff --git a/k356/app/__init__.py b/k356/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/app/asgi.py b/k356/app/asgi.py new file mode 100644 index 0000000..43865fe --- /dev/null +++ b/k356/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for k356 project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_asgi_application() diff --git a/k356/app/settings.py b/k356/app/settings.py new file mode 100644 index 0000000..9efcc7d --- /dev/null +++ b/k356/app/settings.py @@ -0,0 +1,154 @@ +""" +Django settings for k356 project. + +Generated by 'django-admin startproject' using Django 5.1.1. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path +from django.utils.module_loading import import_module + + +import os + + +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-$440wv7cqb$-umfo-x%w_@p3g5kuuk1(!rv#=7*gzndx4_h4ds' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'main', + 'users', + 'items', + + 'django_js_reverse', +] + +STATIC_URL = "/static/" + +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static_source"),) + +STATICFILES_FINDERS = ( + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + # "djangobower.finders.BowerFinder", + # "compressor.finders.CompressorFinder", +) + +STATIC_ROOT = os.path.join(BASE_DIR, "static") + +STORAGES = { + "staticfiles": { + # "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", + } +} + +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 = 'app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, "templates"), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'app.utils.extra_context.extra_context', + ], + }, + }, +] + +WSGI_APPLICATION = 'app.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = {} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +from app.settingsLocal import * + + +for extra_app in EXTRA_APPS: + INSTALLED_APPS.append(extra_app) + + tmp_app = import_module(extra_app) diff --git a/k356/app/urls.py b/k356/app/urls.py new file mode 100644 index 0000000..ed606a3 --- /dev/null +++ b/k356/app/urls.py @@ -0,0 +1,28 @@ +""" +URL configuration for k356 project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path +from django_js_reverse.views import urls_js + + +urlpatterns = [ + path("", include("main.urls")), + path("items/", include("items.urls")), + path("users/", include("users.urls")), + path("admin/", admin.site.urls), + path('reverse.js', urls_js, name='reverse_js'), +] diff --git a/k356/app/utils/__init__.py b/k356/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/app/utils/api/__init__.py b/k356/app/utils/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/app/utils/api/api_list.py b/k356/app/utils/api/api_list.py new file mode 100644 index 0000000..f3ceefb --- /dev/null +++ b/k356/app/utils/api/api_list.py @@ -0,0 +1,21 @@ +def header_for_table(model): + + headers = model.objects.headers() + + return [ + { + "text": value, + "value": key, + } + for key, value in headers.items() + ] + [ + { + "text": "Actions", + "value": "actions", + "sortable": False, + }, + ] + + +def encrypted_fields(model): + return model.Encryption.fields diff --git a/k356/app/utils/extra_context.py b/k356/app/utils/extra_context.py new file mode 100644 index 0000000..2562200 --- /dev/null +++ b/k356/app/utils/extra_context.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.apps import apps + + +from users.models import UserSettings +from pathlib import Path + + +def extra_context(request): + + if not request.user.is_anonymous: + user_settings, __ = UserSettings.objects.get_or_create(user=request.user) + + else: + user_settings = None + + components = [] + for app in apps.get_app_configs(): + p = Path(settings.BASE_DIR) / app.name / "templates/components/" + if p.exists(): + for path in p.iterdir(): + components.append(path.name) + + return { + "user_settings": user_settings, + "templates": { + component: f"components/{component}/template.html" + for component in components + }, + "components": { + component: { + "path": f"components/{component}/vue.js", + "flat_name": component.replace("-", "_").lower(), + } + for component in components + } + } diff --git a/k356/app/utils/models.py b/k356/app/utils/models.py new file mode 100644 index 0000000..d72d45f --- /dev/null +++ b/k356/app/utils/models.py @@ -0,0 +1,62 @@ +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models.fields.related import RelatedField + +User = get_user_model() + + +class BaseQuerySet(models.QuerySet): + def headers(self): + """Return the list of header for a list.""" + + fields = {} + for field in self.model._meta.fields: + if field.name in self.model.Serialization.excluded_fields: + continue + + # if isinstance(field, RelatedField): + # fields[f"{field.name}__name"] = field.verbose_name.capitalize() + + fields[field.name] = field.verbose_name.capitalize() + + return fields + + def serialize(self): + """Serialize a queryset.""" + + fields = [] + for field_name, _ in self.headers().items(): + fields.append(field_name) + + return self.values(*fields) + + +class BaseManager(models.Manager.from_queryset(BaseQuerySet)): + pass + + +class BaseModel(models.Model): + class Meta: + abstract = True + + class Serialization: + # Exclude fields from serialization + excluded_fields = [] + excluded_fields_edit = ["id", "created_at", "last_modified_at"] + + class Encryption: + fields = ["name", "description", "custom_identifier"] + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + name = models.TextField(max_length=2048) + description = models.TextField(max_length=2048) + + custom_identifier = models.TextField(max_length=2048, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + last_modified_at = models.DateTimeField(auto_now=True) + + objects = BaseManager() diff --git a/k356/app/wsgi.py b/k356/app/wsgi.py new file mode 100644 index 0000000..4f0334a --- /dev/null +++ b/k356/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for k356 project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_wsgi_application() diff --git a/k356/items/__init__.py b/k356/items/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/items/admin.py b/k356/items/admin.py new file mode 100644 index 0000000..b9b187a --- /dev/null +++ b/k356/items/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from items.models import ItemType, Item, ItemRelation, Property, LinkedProperty, RelationProperty + +@admin.register(ItemType) +class ItemTypeAdmin(admin.ModelAdmin): + list_display = ("id", ) + + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ("id", ) + + +@admin.register(ItemRelation) +class ItemRelationAdmin(admin.ModelAdmin): + list_display = ("id", ) + + +@admin.register(Property) +class PropertyAdmin(admin.ModelAdmin): + list_display = ("id", "type") + + +@admin.register(LinkedProperty) +class LinkedPropertyAdmin(admin.ModelAdmin): + list_display = ("id", "property__type", "item") + + +@admin.register(RelationProperty) +class RelationPropertyAdmin(admin.ModelAdmin): + list_display = ("id", "property__type", "relation") diff --git a/k356/items/apps.py b/k356/items/apps.py new file mode 100644 index 0000000..d25e634 --- /dev/null +++ b/k356/items/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ItemsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'items' diff --git a/k356/items/migrations/0001_initial.py b/k356/items/migrations/0001_initial.py new file mode 100644 index 0000000..e7159a7 --- /dev/null +++ b/k356/items/migrations/0001_initial.py @@ -0,0 +1,130 @@ +# Generated by Django 5.1.1 on 2024-09-25 17:43 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ItemRelation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parents', to='items.item')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='items.item')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='item', + name='relations', + field=models.ManyToManyField(through='items.ItemRelation', to='items.item'), + ), + migrations.CreateModel( + name='ItemType', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='item', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='items.itemtype'), + ), + migrations.CreateModel( + name='Property', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('type', models.CharField(choices=[('text', 'Text'), ('date', 'Date'), ('datetime', 'Date & time')], default='text', max_length=32)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LinkedProperty', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('value', models.TextField(max_length=2048)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ('item', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_properties', to='items.item')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.property')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='item', + name='properties', + field=models.ManyToManyField(related_name='items', through='items.LinkedProperty', to='items.property'), + ), + migrations.CreateModel( + name='RelationProperty', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('value', models.TextField(max_length=2048)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.usersettings')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.property')), + ('relation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='relation_properties', to='items.itemrelation')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='itemrelation', + name='properties', + field=models.ManyToManyField(related_name='relations', through='items.RelationProperty', to='items.property'), + ), + ] diff --git a/k356/items/migrations/0002_item_custom_identifier_and_more.py b/k356/items/migrations/0002_item_custom_identifier_and_more.py new file mode 100644 index 0000000..1f50d6c --- /dev/null +++ b/k356/items/migrations/0002_item_custom_identifier_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.1 on 2024-09-25 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('items', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + migrations.AddField( + model_name='itemrelation', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + migrations.AddField( + model_name='itemtype', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + migrations.AddField( + model_name='linkedproperty', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + migrations.AddField( + model_name='property', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + migrations.AddField( + model_name='relationproperty', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + ] diff --git a/k356/items/migrations/__init__.py b/k356/items/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/items/models.py b/k356/items/models.py new file mode 100644 index 0000000..b3dd7ad --- /dev/null +++ b/k356/items/models.py @@ -0,0 +1,88 @@ +from app.utils.models import BaseModel +from django.contrib.auth.forms import gettext as _ +from django.db import models +from users.models import UserSettings + + +class ItemBase(BaseModel): + class Meta: + abstract = True + + class Serialization(BaseModel.Serialization): + excluded_fields = BaseModel.Serialization.excluded_fields + ["author"] + excluded_fields_edit = BaseModel.Serialization.excluded_fields_edit + ["author"] + + author = models.ForeignKey(UserSettings, on_delete=models.PROTECT) + + +class ItemType(ItemBase): + name = models.TextField(max_length=2048) + description = models.TextField(max_length=2048) + + +class ItemRelation(ItemBase): + parent = models.ForeignKey( + "items.Item", on_delete=models.CASCADE, related_name="children" + ) + child = models.ForeignKey( + "items.Item", on_delete=models.CASCADE, related_name="parents" + ) + + properties = models.ManyToManyField( + "items.Property", through="items.RelationProperty", related_name="relations" + ) + + +class Item(ItemBase): + name = models.TextField(max_length=2048) + description = models.TextField(max_length=2048) + type = models.ForeignKey(ItemType, on_delete=models.PROTECT) + + relations = models.ManyToManyField( + "items.Item", + through=ItemRelation, + ) + + properties = models.ManyToManyField( + "items.Property", + through="LinkedProperty", + related_name="items", + ) + + +class PropertyType(models.TextChoices): + TEXT = "text", _("Text") + DATE = "date", _("Date") + DATETIME = "datetime", _("Date & time") + # TODO: Add more property types (location, etc) + + +class Property(ItemBase): + type = models.CharField( + max_length=32, choices=PropertyType.choices, default=PropertyType.TEXT + ) + + +class BaseLinkedProperty(ItemBase): + class Meta: + abstract = True + + property = models.ForeignKey(Property, on_delete=models.CASCADE) + + # Value is encrypted too + value = models.TextField(max_length=2048) + + +class LinkedProperty(BaseLinkedProperty): + item = models.ForeignKey( + Item, on_delete=models.CASCADE, null=True, related_name="linked_properties" + ) + + +class RelationProperty(BaseLinkedProperty): + relation = models.ForeignKey( + ItemRelation, + on_delete=models.CASCADE, + null=True, + related_name="relation_properties", + ) diff --git a/k356/items/templates/components/item/template.html b/k356/items/templates/components/item/template.html new file mode 100644 index 0000000..d4d2074 --- /dev/null +++ b/k356/items/templates/components/item/template.html @@ -0,0 +1,5 @@ +{% load i18n %} + +
+ +
diff --git a/k356/items/templates/components/item/vue.js b/k356/items/templates/components/item/vue.js new file mode 100644 index 0000000..1b8a100 --- /dev/null +++ b/k356/items/templates/components/item/vue.js @@ -0,0 +1,4 @@ +item = { + template: "#item", + props: ["crypto_key", "item"], +} diff --git a/k356/items/templates/components/item_list/template.html b/k356/items/templates/components/item_list/template.html new file mode 100644 index 0000000..b389e4f --- /dev/null +++ b/k356/items/templates/components/item_list/template.html @@ -0,0 +1,88 @@ +{% load i18n %} + +
+
+
{% trans "Your items" %}
+
+ + + + + + + + + + +
+
+ +
diff --git a/k356/items/templates/components/item_list/vue.js b/k356/items/templates/components/item_list/vue.js new file mode 100644 index 0000000..3fbc8a3 --- /dev/null +++ b/k356/items/templates/components/item_list/vue.js @@ -0,0 +1,191 @@ +item_list = { + template: "#item_list", + delimiters: ["[[", "]]"], + props: ["crypto_key", "locked"], + + data: function() { + return { + dialog: false, + dialogDelete: false, + editedIndex: -1, + defaultItem: {}, + editedItem: {}, + search: null, + data: {}, + } + }, + + mounted: function() { + + var self = this; + + this.$http.get(Urls["items:list"]()).then(response => { + + Object.keys(response.data.result).forEach(name => { + self.$set(self.data, name, response.data.result[name]); + }); + + self.data.items.forEach(item => { + self.decryptItem(item); + }); + + }).catch(err => { + + Swal.fire({title: "{{_('Error during loading of items.') | escapejs}}", icon: "error", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + + }); + + }, + + computed: { + formTitle () { + return this.editedIndex === -1 ? "{{_('New item') | escapejs}}" : "{{_('Edit item') | escapejs}}" + }, + }, + + watch: { + dialog (val) { + val || this.close() + }, + dialogDelete (val) { + val || this.closeDelete() + }, + }, + + methods: { + + decryptItem (item) { + this.data.items_encrypted.forEach(field => { + decryptWithKey(this.crypto_key, item[field]).then(dec => { + item[field] = dec; + }) + }); + return item; + }, + + editItem (item) { + this.editedIndex = this.data.items.indexOf(item) + this.editedItem = Object.assign({}, item) + this.dialog = true + }, + + deleteItem (item) { + this.editedIndex = this.data.items.indexOf(item) + this.editedItem = Object.assign({}, item) + this.dialogDelete = true + }, + + deleteItemConfirm () { + var item = this.data.items[this.editedIndex]; + + this.item_edition("delete", item).then(response => { + this.data.items.splice(this.data.items.indexOf(item), 1) + Swal.fire({title: "{{_('Item successfully deleted!') | escapejs}}", icon: "success", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + }); + + this.closeDelete() + }, + + close () { + this.dialog = false + this.$nextTick(() => { + this.editedItem = Object.assign({}, this.defaultItem) + this.editedIndex = -1 + }) + }, + + closeDelete () { + this.dialogDelete = false + this.$nextTick(() => { + this.editedItem = Object.assign({}, this.defaultItem) + this.editedIndex = -1 + }) + }, + + item_edition (method, item) { + // Return a Promise + var self = this; + + return new Promise((resolve) => { + + let url = Urls["items:edit"](item.id) + + if (item.id == undefined || item.id == null) { + url = Urls["items:create"]() + } + + let promises = self.data.items_encrypted.map(field => { + // Encrypt all necessary fields + if (item[field] == null) { + return null; + } + + return new Promise((resolve) => { + return encryptWithKey(self.crypto_key, item[field]).then(enc => { + resolve({field: field, value: enc}); + }); + }); + + }).filter(e => e != null); + + Promise.all(promises).then(values => { + + values.forEach(value => { + item[value.field] = value.value; + }); + + self.$http[method](url, item).then(response => { + + resolve(response.data); + + }).catch(err => { + + let msg = "{{_('Error during edition of item') | escapejs}}"; + if (method == "delete") { + msg = "{{_('Error during deletion of item') | escapejs}}"; + } + + Swal.fire({title: msg, icon: "error", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + + }); + + }); + }); + }, + + save () { + if (this.editedIndex > -1) { + + var self = this; + + this.item_edition("post", this.editedItem).then(data => { + + self.data.items.splice(this.data.items.indexOf(self.editedItem), 1) + console.log('pre edit', data.item) + new_item = self.decryptItem(data.item); + console.log('edited item', new_item); + // self.data.items.push(self.decryptItem(data.item)); + + Swal.fire({title: "{{_('Item successfully edited') | escapejs}}", icon: "success", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + + }); + + } else { + + var self = this; + + this.item_edition("post", this.editedItem).then(data => { + new_item = self.decryptItem(data.item); + console.log('new item', new_item); + // self.data.items.push(self.decryptItem(data.item)); + + // console.log(data.item); + + Swal.fire({title: "{{_('Item successfully created!') | escapejs}}", icon: "success", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + }); + + } + this.close() + }, + } +} diff --git a/k356/items/tests.py b/k356/items/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/k356/items/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/k356/items/urls.py b/k356/items/urls.py new file mode 100644 index 0000000..a8cdee7 --- /dev/null +++ b/k356/items/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from items.views import item as item_view + +app_name = "items" + +urlpatterns = [ + path("", item_view.item_list, name="list"), + path("", item_view.item_edit, name="edit"), + path("create", item_view.item_edit, {"id": None}, name="create"), +] diff --git a/k356/items/views/__init__.py b/k356/items/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/items/views/item.py b/k356/items/views/item.py new file mode 100644 index 0000000..a4c3068 --- /dev/null +++ b/k356/items/views/item.py @@ -0,0 +1,90 @@ +import json + +from app.utils.api.api_list import encrypted_fields, header_for_table +from django.contrib.auth.decorators import login_required +from django.db import models +from django.db.models.fields.related import RelatedField +from django.http import JsonResponse +from items.models import Item, ItemType + + +@login_required +def item_list(request): + + items = Item.objects.filter(author=request.user.setting) + + types = ItemType.objects.filter(author=request.user.setting) + + return JsonResponse( + { + "result": { + "items": list(items.serialize()), + "types": list(types.serialize()), + "items_headers": header_for_table(Item), + "types_headers": header_for_table(ItemType), + "items_encrypted": encrypted_fields(Item), + "types_encrypted": encrypted_fields(ItemType), + }, + "count": items.count(), + } + ) + + +@login_required +def item_edit(request, id=None): + """Create/edit item view.""" + + if id: + item = Item.objects.filter(id=id, author=request.user.setting).first() + + else: + item = Item(author=request.user.setting) + + if not item: + return JsonResponse({}, status=404) + + if request.method == "DELETE": + try: + item.delete() + + except Exception: + return JsonResponse({"error": "INVALID_DELETE"}, status=401) + + return JsonResponse({}) + + if request.method != "POST": + return JsonResponse({}, status=405) + + try: + data = json.loads(request.body) + + except Exception: + return JsonResponse({"error": "INVALID_DATA"}, status=401) + + for field in item._meta.fields: + if field.name in item.Serialization.excluded_fields_edit: + continue + + if isinstance(field, RelatedField): + # For now, disregard related field (fk, m2m, 1-1) + if isinstance(field, models.ForeignKey): + setattr(item, f"{field.name}_id", data[field.name]) + + continue + + if field.name not in data: + continue + + setattr(item, field.name, data[field.name]) + + try: + item.save() + + except Exception: + return JsonResponse({"error": "DATA_INVALID"}, status=401) + + return JsonResponse( + { + "item": Item.objects.filter(id=item.id).serialize().first(), + } + ) diff --git a/k356/main/__init__.py b/k356/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/main/admin.py b/k356/main/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/k356/main/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/k356/main/apps.py b/k356/main/apps.py new file mode 100644 index 0000000..167f044 --- /dev/null +++ b/k356/main/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'main' diff --git a/k356/main/migrations/__init__.py b/k356/main/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/main/models.py b/k356/main/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/k356/main/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/k356/main/templates/components/encryption-testing/template.html b/k356/main/templates/components/encryption-testing/template.html new file mode 100644 index 0000000..5a1c38c --- /dev/null +++ b/k356/main/templates/components/encryption-testing/template.html @@ -0,0 +1,21 @@ +{% load i18n %} + +
+
+
{% trans "Encryption testing" %}
+
+

{% trans "The text will be automatically copied to your clipboard." %}

+ + +
+
+ +
+
{% trans "Decryption testing" %}
+
+

{% trans "The text will be automatically copied to your clipboard." %}

+ + +
+
+
diff --git a/k356/main/templates/components/encryption-testing/vue.js b/k356/main/templates/components/encryption-testing/vue.js new file mode 100644 index 0000000..9824ec7 --- /dev/null +++ b/k356/main/templates/components/encryption-testing/vue.js @@ -0,0 +1,34 @@ +encryption_testing = { + template: "#encryption-testing", + props: ["crypto_key"], + data: function() { + return { + text: '', + encrypted: '', + encrypted_text: '', + decrypted: '', + } + }, + methods: { + + encrypt: function(data) { + var self = this; + + encryptWithKey(this.crypto_key, data).then(e => { + self.encrypted = e; + + navigator.clipboard.writeText(self.encrypted); + }) + }, + + decrypt: function(data) { + var self = this; + + decryptWithKey(this.crypto_key, data).then(e => { + self.decrypted = e; + + navigator.clipboard.writeText(self.encrypted); + }) + }, + } +} diff --git a/k356/main/templates/components/k356-loading/template.html b/k356/main/templates/components/k356-loading/template.html new file mode 100644 index 0000000..3e885d9 --- /dev/null +++ b/k356/main/templates/components/k356-loading/template.html @@ -0,0 +1,18 @@ +{% load i18n %} + +
+ {% if user_settings.k356_key %} +
{% trans "K356 is locked" %}
+
+
{% trans "K356 needs an unlock..." %}
+

{% trans "Enter your personnal password in order to unlock your K356." %}

+ +
+ {% else %} +
{% trans "K356 creation" %}
+
+

{% trans "Enter your personnal password in order to create your K356." %}

+ +
+ {% endif %} +
diff --git a/k356/main/templates/components/k356-loading/vue.js b/k356/main/templates/components/k356-loading/vue.js new file mode 100644 index 0000000..36b6126 --- /dev/null +++ b/k356/main/templates/components/k356-loading/vue.js @@ -0,0 +1,109 @@ +const rvalidate = Vue.resource(Urls["users:k356.validate"]); + +k356_loading = { + template: "#k356-loading", + + props: ["crypto_key"], + data: function() { + return { + password: '', + k356_fingerprint: "{{ user_settings.k356_fingerprint }}", + } + }, + + mounted: function() {}, + + methods: { + + generate_import_key: function(password) { + + if (password != null && password != "") { + + var self = this; + window.crypto.subtle.importKey( + "raw", + (new TextEncoder()).encode(password), + "PBKDF2", + true, + ["deriveKey"] + ).then(pkey => { + + window.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: stringToArrayBuffer("salt"), + iterations: 250000, + hash: "SHA-256", + }, + pkey, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ).then(key => { + + self.set_key(key); + + }).catch(function(err) { + + self.set_key(null); + + }); + }).catch(function(err) { + + self.set_key(null); + + }); + + } else { + + this.set_key(null); + + } + + }, + + set_key: function(key) { + + if (key) { + + var self = this; + + encryptWithKey({key: key, uuid: this.crypto_key.uuid}, this.crypto_key.uuid).then(efinger => { + + self.$http.post(Urls["users:k356.validate"](), {fingerprint: efinger}).then(response => { + + if (!response.data.ok) { + + Swal.fire({title: response.data.error, icon: "error", showConfirmButton: false, toast: false}); + + } else { + + self.$emit("update_key", key); + + Swal.fire({title: "Successfully loaded K356!", icon: "success", position:"top-end", showConfirmButton: false, toast: true, timer: 1000}); + + } + }).catch(err => { + + Swal.fire({title: "{{_('Error while verifying encryption check') | escapejs}}", icon: "error", showConfirmButton: false, toast: false}); + + }); + }).catch(err => { + + Swal.fire({title: "{{_('Error while encrypting verification') | escapejs}}", icon: "error", showConfirmButton: false, toast: false}); + + }); + } + + if (key == null) { + Swal.fire({title: "Error while loading K356!", icon: "error", showConfirmButton: false}); + + this.$emit("update_key", null); + } + + this.password = ""; + + }, + + } +} diff --git a/k356/main/templates/main/home.html b/k356/main/templates/main/home.html new file mode 100644 index 0000000..8594a28 --- /dev/null +++ b/k356/main/templates/main/home.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %}{% trans "Home" %}{% endblock %} +{% block body %} + + + +{% endblock %} diff --git a/k356/main/tests.py b/k356/main/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/k356/main/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/k356/main/urls.py b/k356/main/urls.py new file mode 100644 index 0000000..a194fae --- /dev/null +++ b/k356/main/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + + +from . import views + + +app_name = "main" + + +urlpatterns = [ + path("", views.home, name="home"), +] diff --git a/k356/main/views.py b/k356/main/views.py new file mode 100644 index 0000000..62812a7 --- /dev/null +++ b/k356/main/views.py @@ -0,0 +1,7 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required + + +@login_required +def home(request): + return render(request, "base.html", {}) diff --git a/k356/manage.py b/k356/manage.py new file mode 100755 index 0000000..4931389 --- /dev/null +++ b/k356/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', 'app.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/k356/static_source/img/favicon.png b/k356/static_source/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a7473a38f77a83230df34c48bc3231863e3579f8 GIT binary patch literal 24259 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 + + + + {% block title %}{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + {% for name, path in templates.items %} + + {% endfor %} + + + + + + + diff --git a/k356/templates/scripts.js b/k356/templates/scripts.js new file mode 100644 index 0000000..5b897a9 --- /dev/null +++ b/k356/templates/scripts.js @@ -0,0 +1,56 @@ +function stringToArrayBuffer(str) { + var buf = new ArrayBuffer(str.length); + var bufView = new Uint8Array(buf); + for (var i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; +} + + +function arrayBufferToString(str) { + var byteArray = new Uint8Array(str); + var byteString = ''; + for (var i = 0; i < byteArray.byteLength; i++) { + byteString += String.fromCodePoint(byteArray[i]); + } + return byteString; +} + + +function encryptWithKey(key, data) { + // Encrypt data with key. Return a Promise + return new Promise((resolve) => { + window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: stringToArrayBuffer(key.uuid), + }, + key.key, + stringToArrayBuffer(data) + ).then(text => { + + resolve(btoa(arrayBufferToString(text))); + + }); + }); +} + + +function decryptWithKey(key, data) { + // Decrypt data with key. Return a Promise + return new Promise((resolve) => { + window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: stringToArrayBuffer(key.uuid), + }, + key.key, + stringToArrayBuffer(atob(data)) + ).then(text => { + + resolve(arrayBufferToString(text)); + + }); + }) +} diff --git a/k356/users/__init__.py b/k356/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/users/admin.py b/k356/users/admin.py new file mode 100644 index 0000000..4a6fab9 --- /dev/null +++ b/k356/users/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + + +from users.models import UserSettings + + +@admin.action(description="Remove the key from the users") +def remove_key(modeladmin, request, queryset): + queryset.update(k356_key=False, k356_key_fingerprint=None) + + +@admin.register(UserSettings) +class UserSettingsAdmin(admin.ModelAdmin): + list_display = ("user", "k356_key") + + actions = [remove_key] diff --git a/k356/users/apps.py b/k356/users/apps.py new file mode 100644 index 0000000..72b1401 --- /dev/null +++ b/k356/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/k356/users/migrations/0001_initial.py b/k356/users/migrations/0001_initial.py new file mode 100644 index 0000000..0710e15 --- /dev/null +++ b/k356/users/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.1 on 2024-09-25 17:40 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserSettings', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=2048)), + ('description', models.TextField(max_length=2048)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_modified_at', models.DateTimeField(auto_now=True)), + ('k356_key', models.BooleanField(default=False)), + ('k356_key_fingerprint', models.CharField(null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='setting', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/k356/users/migrations/0002_usersettings_custom_identifier.py b/k356/users/migrations/0002_usersettings_custom_identifier.py new file mode 100644 index 0000000..48eaed1 --- /dev/null +++ b/k356/users/migrations/0002_usersettings_custom_identifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-09-25 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='usersettings', + name='custom_identifier', + field=models.TextField(max_length=2048, null=True), + ), + ] diff --git a/k356/users/migrations/__init__.py b/k356/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/k356/users/models.py b/k356/users/models.py new file mode 100644 index 0000000..e074643 --- /dev/null +++ b/k356/users/models.py @@ -0,0 +1,14 @@ +from uuid import uuid4 +from django.contrib.auth import get_user_model +from django.db import models +from app.utils.models import BaseModel + + +User = get_user_model() + + +class UserSettings(BaseModel): + user = models.OneToOneField(User, on_delete=models.PROTECT, related_name="setting") + + k356_key = models.BooleanField(default=False) + k356_key_fingerprint = models.CharField(null=True) diff --git a/k356/users/tests.py b/k356/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/k356/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/k356/users/urls.py b/k356/users/urls.py new file mode 100644 index 0000000..9319a7e --- /dev/null +++ b/k356/users/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + + +from . import views + +app_name = "users" + +urlpatterns = [ + path("k356/validate", views.k356_validate, name="k356.validate"), +] diff --git a/k356/users/views.py b/k356/users/views.py new file mode 100644 index 0000000..8d0fe0f --- /dev/null +++ b/k356/users/views.py @@ -0,0 +1,48 @@ +import json +from django.contrib.auth.decorators import login_required +from django.utils.translation import gettext as _ +from django.http import JsonResponse + + +login_required() +def k356_validate(request): + + us = request.user.setting + if request.method == "POST": + try: + fingerprint = json.loads(request.body)["fingerprint"] + + except Exception as e: + return JsonResponse( + { + "ok": False, + "error": str(e), + } + ) + + if us.k356_key_fingerprint: + if us.k356_key_fingerprint != fingerprint: + return JsonResponse( + { + "ok": False, + "error": _("Unable to verify key."), + } + ) + + else: + us.k356_key = True + us.k356_key_fingerprint = fingerprint + us.save() + + return JsonResponse( + { + "ok": True + } + ) + + return JsonResponse( + { + "key": us.k356_key, + "fingerprint": us.k356_key_fingerprint, + } + )