diff --git a/app/apps/api/serializers.py b/app/apps/api/serializers.py index f5cc969b79c8387116f03f6516d0e001949dffb8..3feb762dd85c532be2073fce3520346be257194a 100644 --- a/app/apps/api/serializers.py +++ b/app/apps/api/serializers.py @@ -12,7 +12,8 @@ from easy_thumbnails.files import get_thumbnailer from api.fields import DisplayChoiceField from users.models import User -from core.models import (Document, +from core.models import (Project, + Document, DocumentPart, Block, Line, @@ -60,6 +61,12 @@ class ScriptSerializer(serializers.ModelSerializer): fields = '__all__' +class ProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = '__all__' + + class PartMoveSerializer(serializers.ModelSerializer): index = serializers.IntegerField() @@ -114,11 +121,18 @@ class DocumentSerializer(serializers.ModelSerializer): valid_block_types = BlockTypeSerializer(many=True, read_only=True) valid_line_types = LineTypeSerializer(many=True, read_only=True) parts_count = serializers.SerializerMethodField() + project = serializers.SlugRelatedField(slug_field='slug', + queryset=Project.objects.all()) class Meta: model = Document - fields = ('pk', 'name', 'transcriptions', 'main_script', 'read_direction', - 'valid_block_types', 'valid_line_types', 'parts_count') + fields = ('pk', 'name', 'project', 'transcriptions', 'main_script', 'read_direction', + 'valid_block_types', 'valid_line_types', 'parts_count', + 'created_at', 'updated_at') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].queryset = Project.objects.for_user(self.context['user']) def get_parts_count(self, document): return document.parts.count() diff --git a/app/apps/api/urls.py b/app/apps/api/urls.py index 928ec985a8265e478e0bdf05e380b396641746cc..e2a8c20f3f14c9c52ac077566a338181cc81c839 100644 --- a/app/apps/api/urls.py +++ b/app/apps/api/urls.py @@ -2,7 +2,8 @@ from django.urls import include, path from rest_framework_nested import routers from rest_framework.authtoken import views -from api.views import (DocumentViewSet, +from api.views import (ProjectViewSet, + DocumentViewSet, UserViewSet, PartViewSet, DocumentTranscriptionViewSet, @@ -16,6 +17,7 @@ from api.views import (DocumentViewSet, router = routers.DefaultRouter() router.register(r'scripts', ScriptViewSet) +router.register(r'projects', ProjectViewSet) router.register(r'documents', DocumentViewSet) router.register(r'user', UserViewSet) router.register(r'types/block', BlockTypeViewSet) diff --git a/app/apps/api/views.py b/app/apps/api/views.py index 66cc6afc8601985e861519aaa3778d2b8929415d..d4429bc3cae4829400d83a71166ee7e8e042047d 100644 --- a/app/apps/api/views.py +++ b/app/apps/api/views.py @@ -16,6 +16,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.serializers import PrimaryKeyRelatedField from api.serializers import (UserOnboardingSerializer, + ProjectSerializer, DocumentSerializer, PartDetailSerializer, PartSerializer, @@ -35,7 +36,8 @@ from api.serializers import (UserOnboardingSerializer, TranscribeSerializer, OcrModelSerializer) -from core.models import (Document, +from core.models import (Project, + Document, DocumentPart, Block, Line, @@ -75,6 +77,12 @@ class ScriptViewSet(ReadOnlyModelViewSet): serializer_class = ScriptSerializer +class ProjectViewSet(ModelViewSet): + queryset = Project.objects.all() + serializer_class = ProjectSerializer + paginate_by = 10 + + class DocumentViewSet(ModelViewSet): queryset = Document.objects.all() serializer_class = DocumentSerializer @@ -86,6 +94,11 @@ class DocumentViewSet(ModelViewSet): Prefetch('valid_line_types', queryset=LineType.objects.order_by('name')), ) + def get_serializer_context(self): + context = super().get_serializer_context() + context['user'] = self.request.user + return context + def form_error(self, msg): return Response({'status': 'error', 'error': msg}, status=400) diff --git a/app/apps/core/admin.py b/app/apps/core/admin.py index fc4881acc459bfda675ba233c15881cad5a6c3e6..97c0787e630228b7fedc96e27e5ffa24b6044775 100644 --- a/app/apps/core/admin.py +++ b/app/apps/core/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from core.models import (Document, +from core.models import (Project, + Document, DocumentPart, Metadata, DocumentMetadata, @@ -23,7 +24,7 @@ class OcrModelDocumentInline(admin.TabularInline): class DocumentAdmin(admin.ModelAdmin): - list_display = ['pk', 'name', 'owner'] + list_display = ['pk', 'name', 'owner', 'project'] inlines = (MetadataInline, OcrModelDocumentInline) @@ -54,6 +55,7 @@ class OcrModelDocumentAdmin(admin.ModelAdmin): list_display = ['document', 'ocr_model', 'trained_on', 'executed_on', 'created_at'] +admin.site.register(Project) admin.site.register(Document, DocumentAdmin) admin.site.register(DocumentPart, DocumentPartAdmin) admin.site.register(LineTranscription, LineTranscriptionAdmin) diff --git a/app/apps/core/forms.py b/app/apps/core/forms.py index dbddbe187612c210da646ed1a973631f1bb9aca5..26a65f8c6cfb9ff3773c77ead6ceb5aec02c6952 100644 --- a/app/apps/core/forms.py +++ b/app/apps/core/forms.py @@ -12,14 +12,21 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from bootstrap.forms import BootstrapFormMixin -from core.models import (Document, Metadata, DocumentMetadata, +from core.models import (Project, Document, Metadata, DocumentMetadata, DocumentPart, OcrModel, OcrModelDocument, Transcription, + BlockType, LineType, AlreadyProcessingException) from users.models import User logger = logging.getLogger(__name__) +class ProjectForm(BootstrapFormMixin, forms.ModelForm): + class Meta: + model = Project + fields = ['name'] + + class DocumentForm(BootstrapFormMixin, forms.ModelForm): def __init__(self, *args, **kwargs): self.request = kwargs.pop('request') @@ -39,12 +46,16 @@ class DocumentForm(BootstrapFormMixin, forms.ModelForm): self.initial['valid_block_types'] = BlockType.objects.filter(default=True) self.initial['valid_line_types'] = LineType.objects.filter(default=True) + self.fields['project'].queryset = Project.objects.for_user(self.request.user) + if self.instance.pk and self.instance.owner != self.request.user: + self.fields['project'].disabled = True + self.fields['valid_block_types'].queryset = block_qs.order_by('name') self.fields['valid_line_types'].queryset = line_qs.order_by('name') class Meta: model = Document - fields = ['name', 'read_direction', 'main_script', + fields = ['project', 'name', 'read_direction', 'main_script', 'valid_block_types', 'valid_line_types'] widgets = { 'valid_block_types': forms.CheckboxSelectMultiple, @@ -52,11 +63,11 @@ class DocumentForm(BootstrapFormMixin, forms.ModelForm): } -class DocumentShareForm(BootstrapFormMixin, forms.ModelForm): +class ProjectShareForm(BootstrapFormMixin, forms.ModelForm): username = forms.CharField(required=False) class Meta: - model = Document + model = Project fields = ['shared_with_groups', 'shared_with_users', 'username'] def __init__(self, *args, **kwargs): @@ -79,10 +90,10 @@ class DocumentShareForm(BootstrapFormMixin, forms.ModelForm): return user def save(self, commit=True): - doc = super().save(commit=commit) + proj = super().save(commit=commit) if self.cleaned_data['username']: - doc.shared_with_users.add(self.cleaned_data['username']) - return doc + proj.shared_with_users.add(self.cleaned_data['username']) + return proj class MetadataForm(BootstrapFormMixin, forms.ModelForm): @@ -141,6 +152,7 @@ class DocumentProcessForm1(BootstrapFormMixin, forms.Form): def process(self): model = self.cleaned_data.get('model') + class DocumentSegmentForm(DocumentProcessForm1): SEG_STEPS_CHOICES = ( ('both', _('Lines and regions')), diff --git a/app/apps/core/migrations/0044_auto_20210427_0946.py b/app/apps/core/migrations/0044_auto_20210427_0946.py new file mode 100644 index 0000000000000000000000000000000000000000..d68d7b4f7142fb5649ccc26392e1d1eef6506bc5 --- /dev/null +++ b/app/apps/core/migrations/0044_auto_20210427_0946.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.20 on 2021-04-27 09:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0043_auto_20210324_1016'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='read_direction', + field=models.CharField(choices=[('ltr', 'Left to right'), ('rtl', 'Right to left')], default='ltr', help_text='The read direction describes the order of the elements in the document, in opposition with the text direction which describes the order of the words in a line and is set by the script.', max_length=3), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=512)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('shared_with_groups', models.ManyToManyField(blank=True, related_name='shared_projects', to='auth.Group', verbose_name='Share with teams')), + ('shared_with_users', models.ManyToManyField(blank=True, related_name='shared_projects', to=settings.AUTH_USER_MODEL, verbose_name='Share with users')), + ], + ), + migrations.AddField( + model_name='document', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.Project'), + ), + ] diff --git a/app/apps/core/migrations/0044_tag_on_documents_and_parts.py.backup b/app/apps/core/migrations/0044_tag_on_documents_and_parts.py.backup new file mode 100644 index 0000000000000000000000000000000000000000..a6f56dc787c442052d2288a6564494a1a0ce586d --- /dev/null +++ b/app/apps/core/migrations/0044_tag_on_documents_and_parts.py.backup @@ -0,0 +1,46 @@ +# Generated by Django 2.2.19 on 2021-03-23 12:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0043_auto_20210324_1016'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('priority', models.PositiveSmallIntegerField(choices=[(5, 'Very high'), (4, 'High'), (3, 'Medium'), (2, 'Low'), (1, 'Very low')], default=1)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PartTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('priority', models.PositiveSmallIntegerField(choices=[(5, 'Very high'), (4, 'High'), (3, 'Medium'), (2, 'Low'), (1, 'Very low')], default=1)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='document', + name='tags', + field=models.ManyToManyField(blank=True, to='core.DocumentTag'), + ), + migrations.AddField( + model_name='documentpart', + name='tags', + field=models.ManyToManyField(blank=True, to='core.PartTag'), + ), + ] diff --git a/app/apps/core/migrations/0045_project_slug.py b/app/apps/core/migrations/0045_project_slug.py new file mode 100644 index 0000000000000000000000000000000000000000..817618e77d1b00a5dfcead4491f762d789faadf3 --- /dev/null +++ b/app/apps/core/migrations/0045_project_slug.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.20 on 2021-04-27 10:22 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0044_auto_20210427_0946'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='slug', + field=models.SlugField(default=datetime.datetime(2021, 4, 27, 10, 22, 51, 880783)), + preserve_default=False, + ), + ] diff --git a/app/apps/core/migrations/0046_auto_20210521_1444.py b/app/apps/core/migrations/0046_auto_20210521_1444.py new file mode 100644 index 0000000000000000000000000000000000000000..f2b7053905979a64a65fcf8ed6d94c8cf1f4894e --- /dev/null +++ b/app/apps/core/migrations/0046_auto_20210521_1444.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.20 on 2021-05-21 14:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0011_update_proxy_permissions'), + ('core', '0045_auto_20210521_1034'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='workflow_state', + field=models.PositiveSmallIntegerField(choices=[(0, 'Draft'), (2, 'Published'), (3, 'Archived')], default=0), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=512)), + ('slug', models.SlugField(unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('shared_with_groups', models.ManyToManyField(blank=True, related_name='shared_projects', to='auth.Group', verbose_name='Share with teams')), + ('shared_with_users', models.ManyToManyField(blank=True, related_name='shared_projects', to=settings.AUTH_USER_MODEL, verbose_name='Share with users')), + ], + options={ + 'ordering': ('-updated_at',), + }, + ), + migrations.AddField( + model_name='document', + name='project', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='core.Project'), + ), + ] diff --git a/app/apps/core/migrations/0046_data_share_doc_to_proj.py b/app/apps/core/migrations/0046_data_share_doc_to_proj.py new file mode 100644 index 0000000000000000000000000000000000000000..5007decefb9efcc6eb7b0ebcb04f618855399483 --- /dev/null +++ b/app/apps/core/migrations/0046_data_share_doc_to_proj.py @@ -0,0 +1,81 @@ +# Generated by Django 2.2.20 on 2021-05-10 13:06 +import time + +from django.db import migrations +from django.template.defaultfilters import slugify + + +def make_slug(proj, Project): + # models in migrations don't have access to models methods ;( + slug = slugify(proj.name) + # check unicity + exists = Project.objects.filter(slug=slug).count() + if not exists: + proj.slug = slug + else: + proj.slug = slug[:40] + hex(int(time.time()))[2:] + + proj.save() + + +def forwards(apps, schema_editor): + User = apps.get_model('users', 'User') + Project = apps.get_model('core', 'Project') + Document = apps.get_model('core', 'Document') + # create user projects + for user in User.objects.all(): + proj, created = Project.objects.get_or_create(name=user.username+"'s Project", + owner=user) + if not proj.slug: + make_slug(proj, Project) + # move documents into projects + user.document_set.update(project=proj) + # move share from docs to created projects + for doc in user.document_set.all(): + for share in doc.shared_with_users.all(): + proj.shared_with_users.add(share) + for share in doc.shared_with_groups.all(): + proj.shared_with_groups.add(share) + + # shared to draft + user.document_set.filter(workflow_state=1).update(workflow_state=0) + + # deal with documents without owner (shouldn't be any but let's be safe) + # move them to admin's + user = User.objects.filter(is_superuser=True).first() + proj, dummy = Project.objects.get_or_create(name=user.username+"'s Project", + owner=user) + if not proj.slug: + make_slug(proj, Project) + for doc in Document.objects.filter(owner=None): + doc.project = proj + doc.save() + # move share from docs to created projects + for doc in user.document_set.all(): + for share in doc.shared_with_users.all(): + proj.shared_with_users.add(share) + for share in doc.shared_with_groups.all(): + proj.shared_with_groups.add(share) + + +def backwards(apps, schema_editor): + Document = apps.get_model('core', 'Document') + for doc in Document.objects.all(): + if doc.project: + for share in doc.project.shared_with_users.all(): + doc.shared_with_users.add(share) + for share in doc.project.shared_with_groups.all(): + doc.shared_with_groups.add(share) + + Document.objects.update(project=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0045_project_slug'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/app/apps/core/migrations/0047_auto_20210510_1512.py b/app/apps/core/migrations/0047_auto_20210510_1512.py new file mode 100644 index 0000000000000000000000000000000000000000..e0b7560b07481f384ba4f2565cbb48015ab1e706 --- /dev/null +++ b/app/apps/core/migrations/0047_auto_20210510_1512.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.20 on 2021-05-10 15:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0046_data_share_doc_to_proj'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ('-updated_at',)}, + ), + migrations.RemoveField( + model_name='document', + name='shared_with_groups', + ), + migrations.RemoveField( + model_name='document', + name='shared_with_users', + ), + migrations.AlterField( + model_name='document', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='core.Project'), + ), + migrations.AlterField( + model_name='document', + name='workflow_state', + field=models.PositiveSmallIntegerField(choices=[(0, 'Draft'), (2, 'Published'), (3, 'Archived')], default=0), + ), + ] diff --git a/app/apps/core/migrations/0047_datamigration_share_doc_to_proj.py b/app/apps/core/migrations/0047_datamigration_share_doc_to_proj.py new file mode 100644 index 0000000000000000000000000000000000000000..d42de99d2ee4210dd6473d8c21a8cf0812fe65fe --- /dev/null +++ b/app/apps/core/migrations/0047_datamigration_share_doc_to_proj.py @@ -0,0 +1,81 @@ +# Generated by Django 2.2.20 on 2021-05-10 13:06 +import time + +from django.db import migrations +from django.template.defaultfilters import slugify + + +def make_slug(proj, Project): + # models in migrations don't have access to models methods ;( + slug = slugify(proj.name) + # check unicity + exists = Project.objects.filter(slug=slug).count() + if not exists: + proj.slug = slug + else: + proj.slug = slug[:40] + hex(int(time.time()))[2:] + + proj.save() + + +def forwards(apps, schema_editor): + User = apps.get_model('users', 'User') + Project = apps.get_model('core', 'Project') + Document = apps.get_model('core', 'Document') + # create user projects + for user in User.objects.all(): + proj, created = Project.objects.get_or_create(name=user.username+"'s Project", + owner=user) + if not proj.slug: + make_slug(proj, Project) + # move documents into projects + user.document_set.update(project=proj) + # move share from docs to created projects + for doc in user.document_set.all(): + for share in doc.shared_with_users.all(): + proj.shared_with_users.add(share) + for share in doc.shared_with_groups.all(): + proj.shared_with_groups.add(share) + + # shared to draft + user.document_set.filter(workflow_state=1).update(workflow_state=0) + + # deal with documents without owner (shouldn't be any but let's be safe) + # move them to admin's + user = User.objects.filter(is_superuser=True).first() + proj, dummy = Project.objects.get_or_create(name=user.username+"'s Project", + owner=user) + if not proj.slug: + make_slug(proj, Project) + for doc in Document.objects.filter(owner=None): + doc.project = proj + doc.save() + # move share from docs to created projects + for doc in user.document_set.all(): + for share in doc.shared_with_users.all(): + proj.shared_with_users.add(share) + for share in doc.shared_with_groups.all(): + proj.shared_with_groups.add(share) + + +def backwards(apps, schema_editor): + Document = apps.get_model('core', 'Document') + for doc in Document.objects.all(): + if doc.project: + for share in doc.project.shared_with_users.all(): + doc.shared_with_users.add(share) + for share in doc.project.shared_with_groups.all(): + doc.shared_with_groups.add(share) + + Document.objects.update(project=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0046_auto_20210521_1444'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/app/apps/core/migrations/0048_auto_20210520_1308.py b/app/apps/core/migrations/0048_auto_20210520_1308.py new file mode 100644 index 0000000000000000000000000000000000000000..31bfc70455cc2db516958f3134cf2ceb29e8f11c --- /dev/null +++ b/app/apps/core/migrations/0048_auto_20210520_1308.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.20 on 2021-05-20 13:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0047_auto_20210510_1512'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='core.Project'), + ), + ] diff --git a/app/apps/core/migrations/0048_auto_20210521_1445.py b/app/apps/core/migrations/0048_auto_20210521_1445.py new file mode 100644 index 0000000000000000000000000000000000000000..4e410d45fd57cb3ba813f08f318998a6554068c5 --- /dev/null +++ b/app/apps/core/migrations/0048_auto_20210521_1445.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.20 on 2021-05-21 14:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0047_datamigration_share_doc_to_proj'), + ] + + operations = [ + migrations.RemoveField( + model_name='document', + name='shared_with_groups', + ), + migrations.RemoveField( + model_name='document', + name='shared_with_users', + ), + migrations.AlterField( + model_name='document', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='core.Project'), + ), + ] diff --git a/app/apps/core/models.py b/app/apps/core/models.py index f199741fb8fa07c77c474e7da5adf6b19a520af3..a9fb102b2b42ee563a1b0402b2654604d38f8348 100644 --- a/app/apps/core/models.py +++ b/app/apps/core/models.py @@ -5,6 +5,7 @@ import os import json import functools import subprocess +import time import uuid from PIL import Image from datetime import datetime @@ -21,8 +22,8 @@ from django.contrib.postgres.fields import JSONField from django.core.validators import FileExtensionValidator from django.dispatch import receiver from django.forms import ValidationError +from django.template.defaultfilters import slugify from django.utils.functional import cached_property -from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from celery.task.control import inspect, revoke @@ -134,32 +135,73 @@ class DocumentMetadata(models.Model): return '%s:%s' % (self.document.name, self.key.name) +class ProjectManager(models.Manager): + def for_user(self, user): + # return the list of editable projects + # Note: Monitor this query + return (Project.objects + .filter(Q(owner=user) + | (Q(shared_with_users=user) + | Q(shared_with_groups__in=user.groups.all()))) + .prefetch_related('shared_with_groups') + .distinct()) + + +class Project(models.Model): + name = models.CharField(max_length=512) + slug = models.SlugField() + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + + shared_with_users = models.ManyToManyField(User, blank=True, + verbose_name=_("Share with users"), + related_name='shared_projects') + shared_with_groups = models.ManyToManyField(Group, blank=True, + verbose_name=_("Share with teams"), + related_name='shared_projects') + + # strict_ontology = + + objects = ProjectManager() + + class Meta: + ordering = ('-updated_at',) + + def __str__(self): + return self.name + + def make_slug(self): + slug = slugify(self.name) + # check unicity + exists = Project.objects.filter(slug=slug).count() + if not exists: + self.slug = slug + else: + self.slug = slug[:40] + hex(int(time.time()))[2:] + + def save(self, *args, **kwargs): + if not self.pk: + self.make_slug() + super().save(*args, **kwargs) + + class DocumentManager(models.Manager): def get_queryset(self): return super().get_queryset().select_related('typology') def for_user(self, user): - # return the list of editable documents - # Note: Monitor this query - return (Document.objects - .filter(Q(owner=user) - | (Q(workflow_state__gt=Document.WORKFLOW_STATE_DRAFT) - & (Q(shared_with_users=user) - | Q(shared_with_groups__in=user.groups.all())))) - .exclude(workflow_state=Document.WORKFLOW_STATE_ARCHIVED) - .prefetch_related('shared_with_groups', 'transcriptions') - .select_related('typology') - .distinct()) + return Document.objects.filter(project__in=Project.objects.for_user(user)) class Document(models.Model): WORKFLOW_STATE_DRAFT = 0 - WORKFLOW_STATE_SHARED = 1 # editable a viewable by shared_with people WORKFLOW_STATE_PUBLISHED = 2 # viewable by the world WORKFLOW_STATE_ARCHIVED = 3 # WORKFLOW_STATE_CHOICES = ( (WORKFLOW_STATE_DRAFT, _("Draft")), - (WORKFLOW_STATE_SHARED, _("Shared")), (WORKFLOW_STATE_PUBLISHED, _("Published")), (WORKFLOW_STATE_ARCHIVED, _("Archived")), ) @@ -176,13 +218,6 @@ class Document(models.Model): workflow_state = models.PositiveSmallIntegerField( default=WORKFLOW_STATE_DRAFT, choices=WORKFLOW_STATE_CHOICES) - shared_with_users = models.ManyToManyField(User, blank=True, - verbose_name=_("Share with users"), - related_name='shared_documents') - shared_with_groups = models.ManyToManyField(Group, blank=True, - verbose_name=_("Share with teams"), - related_name='shared_documents') - main_script = models.ForeignKey(Script, null=True, blank=True, on_delete=models.SET_NULL) read_direction = models.CharField( max_length=3, @@ -201,6 +236,10 @@ class Document(models.Model): metadatas = models.ManyToManyField(Metadata, through=DocumentMetadata, blank=True) + project = models.ForeignKey(Project, + on_delete=models.CASCADE, + related_name='documents') + objects = DocumentManager() class Meta: @@ -214,11 +253,6 @@ class Document(models.Model): Transcription.objects.get_or_create(document=self, name=_(Transcription.DEFAULT_NAME)) return res - @property - def is_shared(self): - return self.workflow_state in [self.WORKFLOW_STATE_PUBLISHED, - self.WORKFLOW_STATE_SHARED] - @property def is_published(self): return self.workflow_state == self.WORKFLOW_STATE_PUBLISHED diff --git a/app/apps/core/urls.py b/app/apps/core/urls.py index 313b2e1e9d5e67116feb03c0f543214e618eb8df..2420d62545807b9cf2eef5a9efe52d053ed0a1eb 100644 --- a/app/apps/core/urls.py +++ b/app/apps/core/urls.py @@ -2,8 +2,10 @@ from django.urls import path from django.views.generic import TemplateView from core.views import (Home, + CreateProject, + ProjectList, DocumentsList, - DocumentDetail, + # DocumentDetail, CreateDocument, UpdateDocument, EditPart, @@ -12,14 +14,20 @@ from core.views import (Home, ModelDelete, ModelCancelTraining, PublishDocument, - ShareDocument, + ShareProject, DocumentPartsProcessAjax) urlpatterns = [ path('', Home.as_view(), name='home'), - path('documents/', DocumentsList.as_view(), name='documents-list'), - path('document/<int:pk>/', DocumentDetail.as_view(), name='document-detail'), - path('document/create/', CreateDocument.as_view(), name='document-create'), + + path('projects/create/', CreateProject.as_view(), name='project-create'), + path('projects/', ProjectList.as_view(), name='projects-list'), + # path('project/<str:slug>/', ProjectDetail.as_view(), name='project-detail'), + path('project/<str:slug>/documents/', DocumentsList.as_view(), name='documents-list'), + path('project/<str:slug>/document/create/', CreateDocument.as_view(), name='document-create'), + path('project/<int:pk>/share/', ShareProject.as_view(), name='project-share'), + + # path('document/<int:pk>/', DocumentDetail.as_view(), name='document-detail'), path('document/<int:pk>/edit/', UpdateDocument.as_view(), name='document-update'), path('document/<int:pk>/parts/edit/', EditPart.as_view(), name='document-part-edit'), path('document/<int:pk>/part/<int:part_pk>/edit/', EditPart.as_view(), @@ -31,7 +39,6 @@ urlpatterns = [ name='model-cancel-training'), path('document/<int:document_pk>/models/', ModelsList.as_view(), name='document-models'), path('document/<int:pk>/publish/', PublishDocument.as_view(), name='document-publish'), - path('document/<int:pk>/share/', ShareDocument.as_view(), name='document-share'), path('document/<int:pk>/process/', DocumentPartsProcessAjax.as_view(), name='document-parts-process'), diff --git a/app/apps/core/views.py b/app/apps/core/views.py index a140d63d3ecd99ef868ee91d9ede80a119aa5424..d8cfd458e7ce1edf8f7b27949464e1f0a9cf1fd6 100644 --- a/app/apps/core/views.py +++ b/app/apps/core/views.py @@ -13,9 +13,9 @@ from django.urls import reverse from django.views.generic import View, TemplateView, ListView, DetailView from django.views.generic import CreateView, UpdateView, DeleteView -from core.models import (Document, DocumentPart, Metadata, +from core.models import (Project, Document, DocumentPart, Metadata, OcrModel, AlreadyProcessingException) -from core.forms import (DocumentForm, MetadataFormSet, DocumentShareForm, +from core.forms import (ProjectForm, DocumentForm, MetadataFormSet, ProjectShareForm, UploadImageForm, DocumentProcessForm) from imports.forms import ImportForm, ExportForm @@ -33,16 +33,47 @@ class Home(TemplateView): return context +class ProjectList(LoginRequiredMixin, ListView): + model = Project + paginate_by = 10 + + def get_queryset(self): + return (Project.objects + .for_user(self.request.user) + .select_related('owner')) + + +class CreateProject(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = Project + form_class = ProjectForm + success_message = _("Project created successfully!") + + def get_success_url(self): + return reverse('projects-list') + + def form_valid(self, form): + form.instance.owner = self.request.user + response = super().form_valid(form) + return response + + class DocumentsList(LoginRequiredMixin, ListView): model = Document paginate_by = 10 def get_queryset(self): - return (Document.objects - .for_user(self.request.user) + self.project = Project.objects.for_user(self.request.user).get(slug=self.kwargs['slug']) + return (Document.objects.filter(project=self.project) .select_related('owner', 'main_script') .annotate(parts_updated_at=Max('parts__updated_at'))) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['project'] = self.project + if self.project.owner == self.request.user: + context['share_form'] = ProjectShareForm(instance=self.project, request=self.request) + return context + class DocumentMixin(): def get_success_url(self): @@ -66,7 +97,9 @@ class DocumentMixin(): obj = super().get_object() try: # we fetched the object already, now we check that the user has perms to edit it - Document.objects.for_user(self.request.user).get(pk=obj.pk) + (Document.objects + .filter(project__in=Project.objects.for_user(self.request.user)) + .get(pk=obj.pk)) except Document.DoesNotExist: raise PermissionDenied return obj @@ -78,6 +111,13 @@ class CreateDocument(LoginRequiredMixin, SuccessMessageMixin, DocumentMixin, Cre success_message = _("Document created successfully!") + def get_form(self, *args, **kwargs): + + form = super().get_form(*args, **kwargs) + form.initial = {'project': Project.objects.get( + slug=self.request.resolver_match.kwargs['slug'])} + return form + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['metadata_form'] = self.get_metadata_formset() @@ -90,8 +130,6 @@ class CreateDocument(LoginRequiredMixin, SuccessMessageMixin, DocumentMixin, Cre if form.is_valid() and metadata_form.is_valid(): return self.form_valid(form, metadata_form) else: - print(form.errors) - print(metadata_form.errors) return self.form_invalid(form) def form_valid(self, form, metadata_form): @@ -113,7 +151,6 @@ class UpdateDocument(LoginRequiredMixin, SuccessMessageMixin, DocumentMixin, Upd context['can_publish'] = self.object.owner == self.request.user if 'metadata_form' not in kwargs: context['metadata_form'] = self.get_metadata_formset(instance=self.object) - context['share_form'] = DocumentShareForm(instance=self.object, request=self.request) return context def post(self, request, *args, **kwargs): @@ -150,33 +187,29 @@ class DocumentImages(LoginRequiredMixin, DocumentMixin, DetailView): return context -class ShareDocument(LoginRequiredMixin, SuccessMessageMixin, DocumentMixin, UpdateView): - model = Document - form_class = DocumentShareForm - success_message = _("Document shared successfully!") +class ShareProject(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Project + form_class = ProjectShareForm + success_message = _("Project shared successfully!") http_method_names = ('post',) - def get_redirect_url(self): - return reverse('document-update', kwargs={'pk': self.object.pk}) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs - def get_queryset(self): - return Document.objects.for_user(self.request.user).select_related('owner') + def get_success_url(self): + return reverse('documents-list', kwargs={'slug': self.object.slug}) - def form_valid(self, form): - if form.instance.workflow_state == Document.WORKFLOW_STATE_DRAFT: - form.instance.workflow_state = Document.WORKFLOW_STATE_SHARED - return super().form_valid(form) + def get_queryset(self): + return Project.objects.for_user(self.request.user).select_related('owner') -class PublishDocument(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class PublishDocument(LoginRequiredMixin, SuccessMessageMixin, DocumentMixin, UpdateView): model = Document fields = ['workflow_state'] http_method_names = ('post',) - def get_queryset(self): - # will raise a 404 instead of a 403 if user can't edit, but avoids a query - return Document.objects.for_user(self.request.user) - def get_success_message(self, form_data): if self.object.is_archived: return _("Document archived successfully!") diff --git a/app/escriptorium/settings.py b/app/escriptorium/settings.py index 4c1ebec8415d05b8725acb96e4f05eb0fbbfd42a..a1ae037cc561dafd6365abbf1136022a9bbf35b0 100644 --- a/app/escriptorium/settings.py +++ b/app/escriptorium/settings.py @@ -139,7 +139,7 @@ AUTH_PASSWORD_VALIDATORS = [ AUTH_USER_MODEL = 'users.User' LOGIN_URL = 'login' -LOGIN_REDIRECT_URL = 'documents-list' +LOGIN_REDIRECT_URL = 'projects-list' LOGOUT_REDIRECT_URL = '/' # Internationalization diff --git a/app/escriptorium/templates/base.html b/app/escriptorium/templates/base.html index ac673a3fa27359b2a874bef5bd2dc37c0398a55a..e88a8886e2fab5c74e9993b6bf7732b6b9c89313 100644 --- a/app/escriptorium/templates/base.html +++ b/app/escriptorium/templates/base.html @@ -63,7 +63,7 @@ <ul class="navbar-nav mr-right"> {% if user.is_authenticated %} <li class="nav-item"> - <a class="nav-link {% block nav-doc-list-class %}{% endblock %}" href="{% url 'documents-list' %}">{% trans "My Documents" %}</a> + <a class="nav-link {% block nav-proj-list-class %}{% endblock %}" href="{% url 'projects-list' %}">{% trans "My Projects" %}</a> </li> <li class="nav-item dropdown"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> diff --git a/app/escriptorium/templates/core/document_form.html b/app/escriptorium/templates/core/document_form.html index baaf9abf1a7800e52d5d28bdbb5248ad876f6e8b..9c61c0dfca1528aeab4d50893a02e1eddfd8a664 100644 --- a/app/escriptorium/templates/core/document_form.html +++ b/app/escriptorium/templates/core/document_form.html @@ -7,7 +7,6 @@ {% block extra_nav %} {% if object and can_publish %} <div class="ml-auto"> - <button type="button" class="nav-item btn btn-primary mr-1" data-toggle="modal" data-target="#shareModal" title="{% trans "Share" %}"><i class="fas fa-share"></i></button> {% comment %} {% if not object.is_published %} <form method="post" class="inline-form" action="{% url 'document-publish' pk=object.pk %}">{% csrf_token %} @@ -42,6 +41,7 @@ {% else %} {% render_field form.name autofocus=1 %} {% endif %} + {% render_field form.project %} {% render_field form.main_script %} {% render_field form.read_direction %} @@ -132,39 +132,6 @@ </div> {% endblock %} -{% block modals %} -{% if share_form %} -<!-- shareModal --> -<div class="modal fade" id="shareModal" tabindex="-1" role="dialog" aria-labelledby="shareModalLabel" aria-hidden="true"> - <div class="modal-dialog" role="document"> - <div class="modal-content"> - <form method="post" action="{% url 'document-share' pk=object.pk %}"> - {% csrf_token %} - - <div class="modal-header"> - <h3 class="modal-title" id="exampleModalLabel">{% trans "Share with" %}</h3> - <button type="button" class="close" data-dismiss="modal" aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - </div> - <div class="modal-body"> - <h5>{% trans "Teams" %}</h5> - {% render_field share_form.shared_with_groups %} - <h5>{% trans "Users" %}</h5> - {% render_field share_form.username %} - {% render_field share_form.shared_with_users %} - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> - <input type="submit" value="{% trans 'Share' %}" class="btn btn-primary"> - </div> - </form> - </div> - </div> -</div> -{% endif %} -{% endblock %} - {% block scripts %} <script type="text/javascript"> {% if user.onboarding %} diff --git a/app/escriptorium/templates/core/document_list.html b/app/escriptorium/templates/core/document_list.html index 3cd72eefb101f353d85d2c9e6a3e39abd3abb312..5bf5cf3513ba44088cfe25781fdf356b3196ce4f 100644 --- a/app/escriptorium/templates/core/document_list.html +++ b/app/escriptorium/templates/core/document_list.html @@ -1,19 +1,20 @@ -{% extends 'base.html' %} -{% load i18n staticfiles thumbnail %} +{% extends 'core/project_nav.html' %} +{% load i18n staticfiles thumbnail bootstrap %} {% block head_title %}{% trans "My Documents" %}{% endblock %} -{% block styles %} -{{ block.super }} -{% endblock %} +{% block nav-docs-active %}active{% endblock %} -{% block nav-doc-list-class %}active{% endblock %} +{% block extra_nav %} +<a href="{% url 'document-create' slug=project.slug %}" class="btn btn-success ml-auto mb-2">{% trans 'Create new Document' %}</a> -{% block body %} -<div class="row"> - <div class="col-md-12 col-md-offset-4"> - <a href="{% url 'document-create' %}" class="btn btn-success float-sm-right">{% trans 'Create new' %}</a> - <h2>{% trans "My Documents" %}</h2> +{% if share_form %} +<button type="button" class="btn btn-primary mb-2 ml-1" data-toggle="modal" data-target="#shareModal" title="{% trans "Share this Project" %}"><i class="fas fa-share"></i></button> +{% endif %} +{% endblock %} +{% block tab_content %} +<div class="row"> + <div class="col-md-12 col-md-offset-4"> <table id="document-list" class="table table-hover"> <tbody> {% for document in object_list %} @@ -65,7 +66,40 @@ {% endfor %} </tbody> </table> - </div> + </div> </div> {% include 'includes/pagination.html' %} {% endblock %} + +{% block modals %} +{% if share_form %} +<!-- shareModal --> +<div class="modal fade" id="shareModal" tabindex="-1" role="dialog" aria-labelledby="shareModalLabel" aria-hidden="true"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <form method="post" action="{% url 'project-share' pk=project.pk %}"> + {% csrf_token %} + + <div class="modal-header"> + <h3 class="modal-title" id="exampleModalLabel">{% trans "Share with" %}</h3> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <h5>{% trans "Teams" %}</h5> + {% render_field share_form.shared_with_groups %} + <h5>{% trans "Users" %}</h5> + {% render_field share_form.username %} + {% render_field share_form.shared_with_users %} + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> + <input type="submit" value="{% trans 'Share' %}" class="btn btn-primary"> + </div> + </form> + </div> + </div> +</div> +{% endif %} +{% endblock %} diff --git a/app/escriptorium/templates/core/project_form.html b/app/escriptorium/templates/core/project_form.html new file mode 100644 index 0000000000000000000000000000000000000000..36e10b24307896d0cb46652060e9387bb39bd00d --- /dev/null +++ b/app/escriptorium/templates/core/project_form.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% load i18n staticfiles bootstrap json %} + +{% block head_title %}{% if object %}{% trans "Update a Project" %}{% else %}{% trans "Create a new Project" %}{% endif %}{% endblock %} + +{% block body %} + <form id="project-form" method="post"> + {% csrf_token %} + <fieldset> + <div class="form-group"> + {% for err in form.non_field_errors %} + <p class="error">{{ err }}</p> + {% endfor %} + </div> + + {% render_field form.name %} + + <input type="submit" value="{% if object %}{% trans 'Update' %}{% else %}{% trans 'Create' %}{% endif %}" class="btn btn-success btn-block my-3" {% if object %}autofocus{% endif %}> + </fieldset> + </form> +{% endblock %} diff --git a/app/escriptorium/templates/core/project_list.html b/app/escriptorium/templates/core/project_list.html new file mode 100644 index 0000000000000000000000000000000000000000..a9751f985d458a19d5bc6e469a170cff80b26017 --- /dev/null +++ b/app/escriptorium/templates/core/project_list.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% load i18n staticfiles %} + +{% block head_title %}{% trans "My Projects" %}{% endblock %} + +{% block nav-proj-list-class %}active{% endblock %} + +{% block body %} +<div class="row"> + <div class="col-md-12 col-md-offset-4"> + <a href="{% url 'project-create' %}" class="btn btn-success float-sm-right">{% trans 'Create new Project' %}</a> + <h2>{% trans "My Projects" %}</h2> + + <table id="project-list" class="table table-hover"> + <tbody> + {% for project in project_list %} + <tr onclick="document.location='{% url 'documents-list' slug=project.slug %}'" role="button"> + <td><a href="{% url 'documents-list' slug=project.slug %}">{{project.name}}</a></td> + <td title="{% trans "Owner" %}">{{project.owner}}</td> + <td>{% blocktrans with count=project.documents.count %}{{ count }} document(s).{% endblocktrans %}</td> + </tr> + {% endfor %} + </tbody> + </table> +{% endblock %} diff --git a/app/escriptorium/templates/core/project_nav.html b/app/escriptorium/templates/core/project_nav.html new file mode 100644 index 0000000000000000000000000000000000000000..3bbd91730c3d4b68f4c48ab6cc0234bdbb53cef4 --- /dev/null +++ b/app/escriptorium/templates/core/project_nav.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load i18n staticfiles bootstrap %} + +{% block body %} +<nav> + <div class="nav nav-tabs mb-3" id="nav-tab" role="tablist"> + <h5 class="nav-item nav-link disabled">{{project.name|capfirst}}</h5> + <a href="{% url 'documents-list' slug=project.slug %}" + class="nav-item nav-link {% block nav-docs-active %}{% endblock %}" + id="nav-docs-tab" + role="tab" aria-controls="nav-docs" aria-selected="true">{% trans "Documents" %}</a> + + {% comment %} + <a class="nav-item nav-link {% block nav-onto-active %}{% endblock %}">{% trans "Ontology" %}</a> + {% endcomment %} + + {% block extra_nav %}{% endblock %} + </div> +</nav> + +{% block tab_content %}{% endblock %} +{% block modals %}{% endblock %} +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index f889b78849fa5a8cdb7121f759d3c39c052f7323..e596ec99fcef606d30031fc732f7a3837c050596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,4 +119,3 @@ volumes: media: postgres: esdata: - logs: