diff --git a/app/apps/core/admin.py b/app/apps/core/admin.py index 0a978610f2f2bb20cd5c65cdc0cb0749248e0b93..a93a63c6bb868a5bc7d2170e99d34dbaaa9cb6b0 100644 --- a/app/apps/core/admin.py +++ b/app/apps/core/admin.py @@ -19,7 +19,7 @@ class MetadataInline(admin.TabularInline): class DocumentAdmin(admin.ModelAdmin): - list_display = ['pk', 'name', 'owner'] + list_display = ['pk', 'name', 'owner', 'project'] inlines = (MetadataInline,) diff --git a/app/apps/core/forms.py b/app/apps/core/forms.py index 6dc919d734a84bf717030e8ccb6227726c86779e..725578f82b2ba10dd638ed49dc24a48b652f9e95 100644 --- a/app/apps/core/forms.py +++ b/app/apps/core/forms.py @@ -44,12 +44,14 @@ 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) + qs = Project.objects.for_user(self.request.user) + self.fields['project'].queryset = qs 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, @@ -57,11 +59,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): @@ -84,10 +86,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): @@ -146,6 +148,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_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/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..c1e758f3190b3c7907faadf17102bed9bef0ea9a --- /dev/null +++ b/app/apps/core/migrations/0046_data_share_doc_to_proj.py @@ -0,0 +1,62 @@ +# 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') + # 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) + +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/models.py b/app/apps/core/models.py index 06cd2eccca42a12a388f7db0f9be161db28accf8..2b6e829a7b6b634d82cf1e760522d34f1d9d98e7 100644 --- a/app/apps/core/models.py +++ b/app/apps/core/models.py @@ -24,7 +24,6 @@ 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 @@ -171,16 +170,21 @@ class Project(models.Model): 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: - slug = slugify(self.name) - print(slug) - # 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:] + self.make_slug() super().save(*args, **kwargs) @@ -189,27 +193,15 @@ class DocumentManager(models.Manager): 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")), ) @@ -226,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, @@ -268,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 84c04fd8fea4acc3cb9c15a731fedf35095df095..2420d62545807b9cf2eef5a9efe52d053ed0a1eb 100644 --- a/app/apps/core/urls.py +++ b/app/apps/core/urls.py @@ -4,9 +4,8 @@ from django.views.generic import TemplateView from core.views import (Home, CreateProject, ProjectList, - ProjectDetail, DocumentsList, - DocumentDetail, + # DocumentDetail, CreateDocument, UpdateDocument, EditPart, @@ -15,7 +14,7 @@ from core.views import (Home, ModelDelete, ModelCancelTraining, PublishDocument, - ShareDocument, + ShareProject, DocumentPartsProcessAjax) urlpatterns = [ @@ -25,10 +24,10 @@ urlpatterns = [ 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('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('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(), @@ -40,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 f6ae04247b490dec2616b1d80815e5cc8b2bd220..00cfc0fa20250ecad7d49501db2d199593e8aabb 100644 --- a/app/apps/core/views.py +++ b/app/apps/core/views.py @@ -15,7 +15,7 @@ from django.views.generic import CreateView, UpdateView, DeleteView from core.models import (Project, Document, DocumentPart, Metadata, OcrModel, AlreadyProcessingException) -from core.forms import (ProjectForm, DocumentForm, MetadataFormSet, DocumentShareForm, +from core.forms import (ProjectForm, DocumentForm, MetadataFormSet, ProjectShareForm, UploadImageForm, DocumentProcessForm) from imports.forms import ImportForm, ExportForm @@ -43,19 +43,6 @@ class ProjectList(LoginRequiredMixin, ListView): .select_related('owner')) -class ProjectDetail(LoginRequiredMixin, DetailView): - model = Project - - def get_object(self): - obj = super().get_object() - try: - # we fetched the object already, now we check that the user has perms to edit it - Project.objects.for_user(self.request.user).get(pk=obj.pk) - except Document.DoesNotExist: - raise PermissionDenied - return obj - - class CreateProject(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Project form_class = ProjectForm @@ -77,13 +64,14 @@ class DocumentsList(LoginRequiredMixin, ListView): def get_queryset(self): self.project = Project.objects.for_user(self.request.user).get(slug=self.kwargs['slug']) return (Document.objects.filter(project=self.project) - # .for_user(self.request.user) .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 @@ -109,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 @@ -121,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() @@ -154,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): @@ -191,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/core/document_form.html b/app/escriptorium/templates/core/document_form.html index baaf9abf1a7800e52d5d28bdbb5248ad876f6e8b..a1563288afac9c6686bc3205d2d90bd3662a4848 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 %} @@ -41,6 +40,7 @@ {% render_field form.name %} {% else %} {% render_field form.name autofocus=1 %} + {% render_field form.project %} {% endif %} {% render_field form.main_script %} @@ -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 7a047a8da0f426e0942c5425d6a533449e67cdb3..5bf5cf3513ba44088cfe25781fdf356b3196ce4f 100644 --- a/app/escriptorium/templates/core/document_list.html +++ b/app/escriptorium/templates/core/document_list.html @@ -1,13 +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 body %} +{% block nav-docs-active %}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> + +{% 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"> - <a href="{% url 'document-create' %}" class="btn btn-success float-sm-right mb-2">{% trans 'Create new Document' %}</a> - <h5><a href="{% url 'projects-list'%}">{% trans "Projects" %}</a> > {{ project.name }} > {% trans "Documents" %}</h5> - <table id="document-list" class="table table-hover"> <tbody> {% for document in object_list %} @@ -59,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_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 %}