First commit

This commit is contained in:
2024-09-26 23:59:03 +02:00
commit f3116a6876
53 changed files with 1822 additions and 0 deletions
View File
+32
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ItemsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'items'
+130
View File
@@ -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'),
),
]
@@ -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),
),
]
View File
+88
View File
@@ -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",
)
@@ -0,0 +1,5 @@
{% load i18n %}
<div>
<input class="form-control" type="text" v-model="item.name">
</div>
@@ -0,0 +1,4 @@
item = {
template: "#item",
props: ["crypto_key", "item"],
}
@@ -0,0 +1,88 @@
{% load i18n %}
<div>
<div class="card mt-4 pt-2 ps-lg-2">
<h5 class="card-header">{% trans "Your items" %}</h5>
<div class="card-body">
<v-data-table
:headers="data.items_headers"
:items="data.items"
:items-per-page="50"
:search="search"
group-by="type"
loading
dense>
<template v-slot:top>
<v-toolbar flat>
<v-text-field v-model="search" append-icon="mdi-magnify" label="Search" single-line hide-details></v-text-field>
<v-divider class="mx-4" insert vertical></v-divider>
<v-spacer></v-spacer>
<v-dialog v-model="dialog" max-width="500px">
<template v-slot:activator="{ on, attrs }">
<v-btn color="primary" dark class="mb-2" v-bind="attrs" v-on="on">{% trans "New item" %}</v-btn>
</template>
<v-card>
<v-card-text>
<v-container>
<v-row>
<v-text-field v-model="editedItem.name" label="Name"></v-text-field>
<v-select
v-model="editedItem.type"
:items="data.types"
label="Type"
item-text="name"
item-value="id"
persistent-hint
>
<template slot="item" slot-scope="data">
[[ data.item.name ]] - [[ data.item.custom_identifier ]]
</template>
</v-select>
<v-textarea v-model="editedItem.description" label="Description"></v-textarea>
<v-text-field v-model="editedItem.custom_identifier" label="Identifier"></v-text-field>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue darken-1" text @click="close">{% trans "Cancel" %}</v-btn>
<v-btn color="blue darken-1" text @click="save">{% trans "Save" %}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="dialogDelete" max-width="500px">
<v-card>
<v-card-title class="text-h5">{% trans "Are you sure you want to delete this item?" %}</v-card-title>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue darken-1" text @click="closeDelete">{% trans "Cancel" %}</v-btn>
<v-btn color="blue darken-1" text @click="deleteItemConfirm">{% trans "OK" %}</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
</template>
<template v-slot:item.actions="{ item }">
<v-icon small class="mr-2" @click="editItem(item)">mdi-pencil</v-icon>
<v-icon small @click="deleteItem(item)">mdi-delete</v-icon>
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">{% trans "Reset" %}</v-btn>
</template>
</v-data-table>
</div>
</div>
</div>
@@ -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()
},
}
}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -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("<uuid:id>", item_view.item_edit, name="edit"),
path("create", item_view.item_edit, {"id": None}, name="create"),
]
View File
+90
View File
@@ -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(),
}
)