From 6493a8c5adda4fc2c24b9337355361aa066bf2a6 Mon Sep 17 00:00:00 2001
From: Robin Tissot <tissotrobin@gmail.com>
Date: Thu, 29 Apr 2021 13:19:36 +0200
Subject: [PATCH] wip

---
 app/apps/core/admin.py                        |  4 +-
 app/apps/core/forms.py                        |  8 ++-
 .../migrations/0044_auto_20210427_0946.py     | 39 ++++++++++++++
 app/apps/core/migrations/0045_project_slug.py | 20 +++++++
 app/apps/core/models.py                       | 54 +++++++++++++++++++
 app/apps/core/urls.py                         | 11 +++-
 app/apps/core/views.py                        | 53 +++++++++++++++---
 app/escriptorium/templates/base.html          |  2 +-
 .../templates/core/document_list.html         | 12 ++---
 .../templates/core/project_form.html          | 21 ++++++++
 .../templates/core/project_list.html          | 25 +++++++++
 docker-compose.yml                            |  3 +-
 12 files changed, 231 insertions(+), 21 deletions(-)
 create mode 100644 app/apps/core/migrations/0044_auto_20210427_0946.py
 create mode 100644 app/apps/core/migrations/0045_project_slug.py
 create mode 100644 app/escriptorium/templates/core/project_form.html
 create mode 100644 app/escriptorium/templates/core/project_list.html

diff --git a/app/apps/core/admin.py b/app/apps/core/admin.py
index bf33b7d0..0a978610 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,
@@ -44,6 +45,7 @@ class OcrModelAdmin(admin.ModelAdmin):
     list_display = ['name', 'job', 'owner', 'script', 'training']
 
 
+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 c591e7fd..6dc919d7 100644
--- a/app/apps/core/forms.py
+++ b/app/apps/core/forms.py
@@ -11,7 +11,7 @@ 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, Transcription,
                          BlockType, LineType, AlreadyProcessingException)
 from users.models import User
@@ -19,6 +19,12 @@ 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')
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 00000000..d68d7b4f
--- /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/0045_project_slug.py b/app/apps/core/migrations/0045_project_slug.py
new file mode 100644
index 00000000..817618e7
--- /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/models.py b/app/apps/core/models.py
index 9299c1bc..06cd2ecc 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,6 +22,7 @@ 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 _
@@ -134,6 +136,54 @@ 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 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:]
+        super().save(*args, **kwargs)
+
+
 class DocumentManager(models.Manager):
     def get_queryset(self):
         return super().get_queryset().select_related('typology')
@@ -201,6 +251,10 @@ class Document(models.Model):
 
     metadatas = models.ManyToManyField(Metadata, through=DocumentMetadata, blank=True)
 
+    project = models.ForeignKey(Project, null=True, blank=True,
+                                on_delete=models.CASCADE,
+                                related_name='documents')
+
     objects = DocumentManager()
 
     class Meta:
diff --git a/app/apps/core/urls.py b/app/apps/core/urls.py
index 313b2e1e..84c04fd8 100644
--- a/app/apps/core/urls.py
+++ b/app/apps/core/urls.py
@@ -2,6 +2,9 @@ from django.urls import path
 from django.views.generic import TemplateView
 
 from core.views import (Home,
+                        CreateProject,
+                        ProjectList,
+                        ProjectDetail,
                         DocumentsList,
                         DocumentDetail,
                         CreateDocument,
@@ -17,7 +20,13 @@ from core.views import (Home,
 
 urlpatterns = [
     path('', Home.as_view(), name='home'),
-    path('documents/', DocumentsList.as_view(), name='documents-list'),
+
+    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('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>/edit/', UpdateDocument.as_view(), name='document-update'),
diff --git a/app/apps/core/views.py b/app/apps/core/views.py
index 1304370d..f6ae0424 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, DocumentShareForm,
                         UploadImageForm, DocumentProcessForm)
 from imports.forms import ImportForm, ExportForm
 
@@ -33,16 +33,59 @@ 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 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
+    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)
+                # .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
+        return context
+
 
 class DocumentMixin():
     def get_success_url(self):
@@ -90,8 +133,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):
diff --git a/app/escriptorium/templates/base.html b/app/escriptorium/templates/base.html
index ac673a3f..e88a8886 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_list.html b/app/escriptorium/templates/core/document_list.html
index 3cd72eef..7a047a8d 100644
--- a/app/escriptorium/templates/core/document_list.html
+++ b/app/escriptorium/templates/core/document_list.html
@@ -2,17 +2,11 @@
 {% load i18n staticfiles thumbnail %}
 {% block head_title %}{% trans "My Documents" %}{% endblock %}
 
-{% block styles %}
-{{ block.super }}
-{% endblock %}
-
-{% block nav-doc-list-class %}active{% endblock %}
-
 {% 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>
+    <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>
diff --git a/app/escriptorium/templates/core/project_form.html b/app/escriptorium/templates/core/project_form.html
new file mode 100644
index 00000000..36e10b24
--- /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 00000000..a9751f98
--- /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/docker-compose.yml b/docker-compose.yml
index f8f155c8..f8eed4a9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,7 +32,7 @@ services:
     env_file: variables.env
 
   redis:
-    image: redis:3.0-alpine
+    image: sickp/alpine-redis:4.0.6
 
   nginx:
     image: registry.gitlab.inria.fr/scripta/escriptorium/nginx
@@ -120,4 +120,3 @@ volumes:
    media:
    postgres:
    esdata:
-   logs:
-- 
GitLab