commit f3116a687662e1c6d02f1549af7432961f602d78 Author: Loïc Gremaud Date: Thu Sep 26 23:59:03 2024 +0200 First commit 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 0000000..a7473a3 Binary files /dev/null and b/k356/static_source/img/favicon.png differ diff --git a/k356/templates/base.html b/k356/templates/base.html new file mode 100644 index 0000000..ea05565 --- /dev/null +++ b/k356/templates/base.html @@ -0,0 +1,163 @@ +{% load i18n static %} + + + + + + {% 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, + } + )