diff --git a/app/apps/api/serializers.py b/app/apps/api/serializers.py index 705c455a951f9d37fee0902a23c30a87b918b976..a84389e0015fe1e983a303faefd8e3e032b7357e 100644 --- a/app/apps/api/serializers.py +++ b/app/apps/api/serializers.py @@ -151,7 +151,7 @@ class BlockSerializer(serializers.ModelSerializer): class LineTranscriptionSerializer(serializers.ModelSerializer): class Meta: model = LineTranscription - fields = ('pk', 'line', 'transcription', 'content', + fields = ('pk', 'line', 'transcription', 'content', 'graphs', 'versions', 'version_author', 'version_source', 'version_updated_at') def cleanup(self, data): diff --git a/app/apps/api/views.py b/app/apps/api/views.py index baea888e47c0dfc412fb693eab490deb1a67def6..cad44e8f1912bceeb03155464e8691a0ba3f8f4d 100644 --- a/app/apps/api/views.py +++ b/app/apps/api/views.py @@ -181,6 +181,34 @@ class PartViewSet(DocumentPermissionMixin, ModelViewSet): serializer = LineOrderSerializer(document_part.lines.all(), many=True) return Response({'status': 'done', 'lines': serializer.data}, status=200) + @action(detail=True, methods=['post']) + def rotate(self, request, document_pk=None, pk=None): + document_part = DocumentPart.objects.get(pk=pk) + angle = self.request.data.get('angle') + if angle: + document_part.rotate(angle) + return Response({'status': 'done'}, status=200) + else: + return Response({'error': "Post an angle."}, + status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def crop(self, request, document_pk=None, pk=None): + document_part = DocumentPart.objects.get(pk=pk) + x1 = self.request.data.get('x1') + y1 = self.request.data.get('y1') + x2 = self.request.data.get('x2') + y2 = self.request.data.get('y2') + if (x1 is not None + and y1 is not None + and x2 is not None + and y2 is not None): + document_part.crop(x1, y1, x2, y2) + return Response({'status': 'done'}, status=200) + else: + return Response({'error': "Post corners as x1, y1 (top left) and x2, y2 (bottom right)."}, + status=status.HTTP_400_BAD_REQUEST) + class DocumentTranscriptionViewSet(ModelViewSet): # Note: there is no dedicated Transcription viewset, it's always in the context of a Document diff --git a/app/apps/core/models.py b/app/apps/core/models.py index 2ad1e89fb56c23fd5a349999c03f5d718e28269f..162a718152de1ee70bb08e8a5b8167dcefd991b1 100644 --- a/app/apps/core/models.py +++ b/app/apps/core/models.py @@ -8,6 +8,7 @@ import subprocess import uuid from PIL import Image from datetime import datetime +from shapely import affinity from shapely.geometry import Polygon, LineString from django.db import models, transaction @@ -715,6 +716,8 @@ class DocumentPart(OrderedModel): line=line, transcription=trans) for pred in it: lt.content = pred.prediction + lt.graphs = [(char, pred.cuts[i], float(pred.confidences[i])) + for i, char in enumerate(pred.prediction)] lt.save() else: Transcription.objects.get_or_create( @@ -781,6 +784,102 @@ class DocumentPart(OrderedModel): return to_calc + def rotate(self, angle): + """ + Rotates everything in this document part around the center by a given angle (in degrees): + images, lines and regions. + Changes the file system image path to deal with browser cache. + """ + angle_match = re.search(r'_rot(\d+)', self.image.name) + old_angle = angle_match and int(angle_match.group(1)) or 0 + new_angle = (old_angle + angle) % 360 + + def update_name(fpath, old_angle=old_angle, new_angle=new_angle): + # we need to change the name of the file to avoid all kind of cache issues + if old_angle: + if new_angle: + new_name = re.sub(r'(_rot)'+str(old_angle), r'\g<1>'+str(new_angle), fpath) + else: + new_name = re.sub(r'_rot'+str(old_angle), '', fpath) + else: + # if there was no angle before, there is one now + name, ext = os.path.splitext(fpath) + new_name = f'{name}_rot{new_angle}{ext}' + return new_name + + # rotate image + with Image.open(self.image.file.name) as im: + # store center point while it's open with old bounds + center = (im.width/2, im.height/2) + rim = im.rotate(360-angle, expand=True, fillcolor=None) + + # the image size is shifted so we need to calculate by which offset + # to update points accordingly + new_center = (rim.width/2, rim.height/2) + offset = (center[0]-new_center[0], center[1]-new_center[1]) + + # Note: self.image.file.name (full path) != self.image.name (relative path) + rim.save(update_name(self.image.file.name)) + rim.close() + # save the updated file name in db + self.image = update_name(self.image.name) + + # rotate bw image + if self.bw_image: + with Image.open(self.bw_image.file.name) as im: + rim = im.rotate(360-angle, expand=True) + rim.save(update_name(self.bw_image.file.name)) + rim.close() + self.bw_image = update_name(self.bw_image.name) + + self.save() + + get_thumbnailer(self.image).get_thumbnail(settings.THUMBNAIL_ALIASES['']['large']) + + # rotate lines + for line in self.lines.all(): + if line.baseline: + poly = affinity.rotate(LineString(line.baseline), angle, origin=center) + line.baseline = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.coords] + if line.mask: + poly = affinity.rotate(Polygon(line.mask), angle, origin=center) + line.mask = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords] + line.save() + + # rotate regions + for region in self.blocks.all(): + poly = affinity.rotate(Polygon(region.box), angle, origin=center) + region.box = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords] + region.save() + + def crop(self, x1, y1, x2, y2): + """ + Crops the image ouside the rectangle defined + by top left (x1, y1) and bottom right (x2, y2) points. + Moves the lines and regions accordingly. + """ + with Image.open(self.image.file.name) as im: + cim = im.crop((x1, y1, x2, y2)) + cim.save(self.image.file.name) + cim.close() + + if self.bw_image: + with Image.open(self.image.file.name) as im: + cim = im.crop((x1, y1, x2, y2)) + cim.save(self.image.file.name) + cim.close() + + for line in self.lines.all(): + if line.baseline: + line.baseline = [(int(x-x1), int(y-y1)) for x, y in line.baseline] + if line.mask: + line.mask = [(int(x-x1), int(y-y1)) for x, y in line.mask] + line.save() + + for region in self.blocks.all(): + region.box = [(int(x-x1), int(y-y1)) for x, y in region.box] + region.save() + def enforce_line_order(self): # django-ordered-model doesn't care about unicity and linearity... lines = self.lines.order_by('order', 'pk') @@ -803,6 +902,8 @@ def validate_polygon(value): def validate_2_points(value): + if value is None: + return if len(value) < 2: raise ValidationError( _('Polygon needs to have at least 2 points, it has %(length)d: %(value)s.'), @@ -810,6 +911,8 @@ def validate_2_points(value): def validate_3_points(value): + if value is None: + return if len(value) < 3: raise ValidationError( _('Polygon needs to have at least 3 points, it has %(length)d: %(value)s.'), diff --git a/app/apps/core/static/js/edit/components/source_panel.js b/app/apps/core/static/js/edit/components/source_panel.js index 050d8f856bfd26909091188ad1554cd36611113f..0b78d6213be97f2c2c5765009dab408c43fb3d37 100644 --- a/app/apps/core/static/js/edit/components/source_panel.js +++ b/app/apps/core/static/js/edit/components/source_panel.js @@ -12,5 +12,10 @@ const SourcePanel = BasePanel.extend({ this.$parent.zoom.register( this.$el.querySelector('#source-zoom-container'), {map: true}); + }, + methods: { + rotate(angle) { + this.part.rotate(angle); + } } }); diff --git a/app/apps/core/static/js/edit/components/trans_modal.js b/app/apps/core/static/js/edit/components/trans_modal.js index eebfeb4b73e939c8a69a52efcbfae0a4e08f4561..3dfae0550a4a45a0d89ea43865ffa7104fdd4d01 100644 --- a/app/apps/core/static/js/edit/components/trans_modal.js +++ b/app/apps/core/static/js/edit/components/trans_modal.js @@ -26,6 +26,9 @@ const TranscriptionModal = Vue.component('transcriptionmodal', { $(this.$el).draggable({handle: '.modal-header'}); $(this.$el).resizable(); this.computeStyles(); + + let input = this.$el.querySelector('#trans-input'); + input.focus(); }, watch: { line(new_, old_) { @@ -82,40 +85,86 @@ const TranscriptionModal = Vue.component('transcriptionmodal', { }.bind(this)).join(''); }, - computeStyles() { - // this.zoom.reset(); + getLineAngle() { + let p1 = this.line.baseline[0]; + let p2 = this.line.baseline[this.line.baseline.length-1]; + return Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * 180 / Math.PI; + }, + + getRotatedLineBBox() { + // create temporary polygon to calculate the line bounding box + if (this.line.mask) { + var maskPoints = this.line.mask.map( + pt => Math.round(pt[0])+ ','+ + Math.round(pt[1])).join(' '); + } else { + // TODO + } + let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + let tmppoly = document.createElementNS('http://www.w3.org/2000/svg', + 'polygon'); + tmppoly.setAttributeNS(null, 'points', maskPoints); + tmppoly.setAttributeNS(null, 'fill', 'red'); + + // calculate rotation needed to get the line horizontal + let target_angle = READ_DIRECTION == 'rtl' ? 180 : 0; + let angle = target_angle - this.getLineAngle(); + + // apply it to the polygon and get the resulting bbox + let transformOrigin = this.$parent.part.image.size[0]/2+'px '+this.$parent.part.image.size[1]/2+'px'; + tmppoly.style.transformOrigin = transformOrigin; + tmppoly.style.transform = 'rotate('+angle+'deg)'; + svg.appendChild(tmppoly); + document.body.appendChild(svg); + let bbox = tmppoly.getBoundingClientRect(); + let width = bbox.width; + let height = bbox.height + let top = bbox.top - svg.getBoundingClientRect().top; + let left = bbox.left - svg.getBoundingClientRect().left; + document.body.removeChild(svg); // done its job + return {width: width, height: height, top: top, left: left, angle: angle}; + }, + + computeImgStyles(bbox, ratio, lineHeight, hContext) { let modalImgContainer = this.$el.querySelector('#modal-img-container'); let img = modalImgContainer.querySelector('img#line-img'); - let hContext = 0.6; // vertical context added around the line, in percentage - - let poly = this.line.mask || this.line.baseline; - let minx = Math.min.apply(null, poly.map(pt => pt[0])); - let miny = Math.min.apply(null, poly.map(pt => pt[1])); - let maxx = Math.max.apply(null, poly.map(pt => pt[0])); - let maxy = Math.max.apply(null, poly.map(pt => pt[1])); - let width = maxx - minx; - let height = maxy - miny; - - // we use the same same vertical context horizontaly - let ratio = modalImgContainer.clientWidth / (width + (2*height*hContext)); - var MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-200) / 3)); - let lineHeight = Math.max(30, Math.round(height*ratio)); - if (lineHeight > MAX_HEIGHT) { - // change the ratio so that the image can not get too big - ratio = (MAX_HEIGHT/lineHeight)*ratio; - lineHeight = MAX_HEIGHT; - } + + let context = hContext*lineHeight; let visuHeight = lineHeight + 2*context; modalImgContainer.style.height = visuHeight+'px'; - img.style.width = this.$parent.part.image.size[0]*ratio +'px'; - let top = Math.round(miny*ratio)-context; - let left = Math.round(minx*ratio)-context; - let right = Math.round(maxx*ratio)-context; - img.style.top = -top+'px'; - img.style.left = -left+'px'; + let top = -(bbox.top*ratio - context); + let left = -(bbox.left*ratio - context); + // modalImgContainer.style.transform = 'scale('+ratio+')'; + let imgWidth = this.$parent.part.image.size[0]*ratio +'px'; + let transformOrigin = this.$parent.part.image.size[0]*ratio/2+'px '+this.$parent.part.image.size[1]*ratio/2+'px'; + let transform = 'translate('+left+'px, '+top+'px) rotate('+bbox.angle+'deg)'; + img.style.width = imgWidth; + img.style.transformOrigin = transformOrigin; + img.style.transform = transform; + + // Overlay + let overlay = modalImgContainer.querySelector('.overlay'); + if (this.line.mask) { + let maskPoints = this.line.mask.map( + pt => Math.round(pt[0]*ratio)+ ','+ + Math.round(pt[1]*ratio)).join(' '); + let polygon = overlay.querySelector('polygon'); + polygon.setAttribute('points', maskPoints); + overlay.style.width = imgWidth; + overlay.style.height = this.$parent.part.image.size[1]*ratio+'px'; + overlay.style.transformOrigin = transformOrigin; + overlay.style.transform = transform; + overlay.style.display = 'block'; + } else { + // TODO: fake mask?! + overlay.style.display = 'none'; + } + }, + + computeInputStyles(bbox, ratio, lineHeight, hContext) { // Content input let container = this.$el.querySelector('#trans-modal #trans-input-container'); let input = container.querySelector('#trans-input'); @@ -128,18 +177,21 @@ const TranscriptionModal = Vue.component('transcriptionmodal', { ruler.style.whiteSpace="nowrap" container.appendChild(ruler); - let fontSize = Math.round(lineHeight*0.7); // Note could depend on the script + let context = hContext*lineHeight; + let fontSize = Math.max(15, Math.round(lineHeight*0.7)); // Note could depend on the script ruler.style.fontSize = fontSize+'px'; input.style.fontSize = fontSize+'px'; - input.style.height = 'auto'; + input.style.height = Math.round(fontSize*1.1)+'px'; if (READ_DIRECTION == 'rtl') { container.style.marginRight = context+'px'; } else { + // left to right + // TODO: deal with other directions container.style.marginLeft = context+'px'; } if (content) { - let lineWidth = width*ratio; + let lineWidth = bbox.width*ratio; var scaleX = Math.min(5, lineWidth / ruler.clientWidth); scaleX = Math.max(0.2, scaleX); input.style.transform = 'scaleX('+ scaleX +')'; @@ -149,20 +201,28 @@ const TranscriptionModal = Vue.component('transcriptionmodal', { input.style.width = '100%'; //'calc(100% - '+context+'px)'; } container.removeChild(ruler); // done its job + }, - input.focus(); + computeStyles() { + /* + Centers the image on the line (zoom + rotation) + Modifies input font size and height to match the image + */ + let modalImgContainer = this.$el.querySelector('#modal-img-container'); - // Overlay - let overlay = modalImgContainer.querySelector('.overlay'); - if (this.line.mask) { - let maskPoints = this.line.mask.map( - pt => Math.round(pt[0]*ratio-left)+ ','+ - Math.round(pt[1]*ratio-top)).join(' '); - overlay.querySelector('polygon').setAttribute('points', maskPoints); - overlay.style.display = 'block'; - } else { - overlay.style.display = 'none'; + bbox = this.getRotatedLineBBox(); + let hContext = 0.3; // vertical context added around the line, in percentage + let ratio = modalImgContainer.clientWidth / (bbox.width + (2*bbox.height*hContext)); + let MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-230) / 3)); + let lineHeight = Math.max(30, Math.round(bbox.height*ratio)); + if (lineHeight > MAX_HEIGHT) { + // change the ratio so that the image can not get too big + ratio = (MAX_HEIGHT/lineHeight)*ratio; + lineHeight = MAX_HEIGHT; } - } + + this.computeImgStyles(bbox, ratio, lineHeight, hContext); + this.computeInputStyles(bbox, ratio, lineHeight, hContext); + }, }, }); diff --git a/app/apps/core/static/js/edit/store/part.js b/app/apps/core/static/js/edit/store/part.js index 7757e0826325576694a36ed989ab3f207735d0c1..241bf9b4b318d21837090802f9e46dae4bc12b92 100644 --- a/app/apps/core/static/js/edit/store/part.js +++ b/app/apps/core/static/js/edit/store/part.js @@ -336,6 +336,16 @@ const partStore = { } this.debouncedRecalculateOrdering(); }, + rotate(angle, callback) { + let uri = this.getApiPart() + 'rotate/'; + this.push(uri, {angle: angle}, method="post") + .then(function(data) { + this.reload(callback); + }.bind(this)) + .catch(function(error) { + console.log('couldnt rotate!', error); + }); + }, createRegion(region, callback) { let uri = this.getApiPart() + 'blocks/'; @@ -503,5 +513,10 @@ const partStore = { this.reset(); this.fetchPart(this.next, cb); } + }, + reload(cb) { + let pk = this.pk; + this.reset(); + this.fetchPart(pk, cb); } }; diff --git a/app/apps/core/tasks.py b/app/apps/core/tasks.py index 8b7ec0adb1fea32d7b5f9323973baed9e5f6cd9d..3f712953920ff692035b708404fbba40725742ea 100644 --- a/app/apps/core/tasks.py +++ b/app/apps/core/tasks.py @@ -4,6 +4,7 @@ import logging import numpy as np import os.path import shutil +from itertools import groupby from django.apps import apps from django.conf import settings @@ -115,9 +116,12 @@ def binarize(instance_pk, user_pk=None, binarizer=None, threshold=None, **kwargs def make_segmentation_training_data(part): return { 'image': part.image.path, - 'baselines': [{'script': 'default', 'baseline': bl} + 'baselines': [{'script': bl.typology.name or 'default', + 'baseline': bl} for bl in part.lines.values_list('baseline', flat=True) if bl], - 'regions': {'default': [r.box for r in part.blocks.all().only('box')]} + 'regions': {typo: regs for typo, regs in groupby( + (r.box for r in part.blocks.all().order_by('typology')), + key=lambda reg: reg.typology.name)} } diff --git a/app/apps/users/admin.py b/app/apps/users/admin.py index 6af1d512c76cdf156087be1e68471861792ddc2a..423dac64946e2f06d9d7eb8ca6d6851f0ac954da 100644 --- a/app/apps/users/admin.py +++ b/app/apps/users/admin.py @@ -6,7 +6,7 @@ from django.contrib import messages from django.utils.translation import ngettext -from users.models import User, ResearchField, Invitation, ContactUs +from users.models import User, ResearchField, Invitation, ContactUs, GroupOwner class MyUserChangeForm(UserChangeForm): @@ -39,7 +39,8 @@ class MyUserAdmin(UserAdmin): class InvitationAdmin(admin.ModelAdmin): date_hierarchy = 'created_at' list_filter = ('workflow_state', 'group') - list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name', 'sender', 'workflow_state') + list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name', + 'sender', 'workflow_state') readonly_fields = ('sender', 'recipient', 'token', 'created_at', 'sent_at', 'workflow_state') actions = ['resend'] @@ -63,4 +64,4 @@ admin.site.register(User, MyUserAdmin) admin.site.register(ResearchField) admin.site.register(Invitation, InvitationAdmin) admin.site.register(ContactUs) - +admin.site.register(GroupOwner) diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py index 669d26727521d2315ff83d589fccb9ca4b235981..7ff6d17af1968e2ab2e3be900eaeb1d0d4f23e70 100644 --- a/app/apps/users/forms.py +++ b/app/apps/users/forms.py @@ -1,10 +1,12 @@ from django import forms -from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from django.db.models import Q +from django.contrib.auth.forms import UserCreationForm from django.utils.translation import gettext as _ -from captcha.fields import CaptchaField +from django.contrib.auth.models import Group +from captcha.fields import CaptchaField from bootstrap.forms import BootstrapFormMixin -from users.models import Invitation, User, ContactUs +from users.models import Invitation, User, ContactUs, GroupOwner class InvitationForm(BootstrapFormMixin, forms.ModelForm): @@ -24,11 +26,43 @@ class InvitationForm(BootstrapFormMixin, forms.ModelForm): def save(self, commit=True): invitation = super().save(commit=False) invitation.sender = self.sender - invitation.save() - invitation.send(self.request) + if commit: + invitation.save() + invitation.send(self.request) return invitation +class GroupInvitationForm(InvitationForm): + recipient_id = forms.CharField(label=_("Email or username.")) + + class Meta: + model = Invitation + fields = ['recipient_id', 'group'] + + def clean_recipient_id(self): + # we don't throw an error on purpose to avoid fishing + try: + return User.objects.get(Q(email=self.data.get('recipient_id')) | + Q(username=self.data.get('recipient_id'))) + except User.DoesNotExist: + return None + + def clean(self): + data = super().clean() + return data + + def save(self, commit=True): + recipient = self.cleaned_data['recipient_id'] + print('recipient', recipient) + if recipient: + invitation = super().save(commit=False) + invitation.recipient = recipient + if commit: + invitation.save() + invitation.send(self.request) + return invitation + + class InvitationAcceptForm(BootstrapFormMixin, UserCreationForm): """ This is a registration form since a user is created. @@ -55,10 +89,61 @@ class ProfileForm(BootstrapFormMixin, forms.ModelForm): fields = ('email', 'first_name', 'last_name') -class ContactUsForm(BootstrapFormMixin, forms.ModelForm): +class GroupForm(BootstrapFormMixin, forms.ModelForm): + class Meta: + model = Group + fields = ('name',) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('request').user + super().__init__(*args, **kwargs) + + def save(self, commit=True): + group = super().save(commit=True) + group.user_set.add(self.user) + GroupOwner.objects.create( + group=group, + owner=self.user) + return group + - message = forms.CharField(widget=forms.Textarea(attrs={ - 'placeholder': 'Message : Please precise your institution or research center if applicable'})) +class RemoveUserFromGroup(forms.ModelForm): + user = forms.ModelChoiceField(queryset=User.objects.all()) + + class Meta: + model = Group + fields = ('user',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['user'].queryset = self.instance.user_set.exclude( + pk=self.instance.groupowner.owner.pk) + + def save(self, commit=True): + self.instance.user_set.remove(self.cleaned_data['user']) + + +class TransferGroupOwnershipForm(forms.ModelForm): + user = forms.ModelChoiceField(queryset=User.objects.all()) + + class Meta: + model = Group + fields = ('user',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['user'].queryset = self.instance.user_set.exclude( + pk=self.instance.groupowner.owner.pk) + + def save(self, commit=True): + self.instance.groupowner.owner = self.cleaned_data['user'] + self.instance.groupowner.save() + + +class ContactUsForm(BootstrapFormMixin, forms.ModelForm): + message = forms.CharField( + label=_("Message : Please precise your institution or research center if applicable"), + widget=forms.Textarea) captcha = CaptchaField() class Meta: diff --git a/app/apps/users/migrations/0011_groupowner.py b/app/apps/users/migrations/0011_groupowner.py new file mode 100644 index 0000000000000000000000000000000000000000..6522124dafa03750ff7321d16428d6fc5bb7f53d --- /dev/null +++ b/app/apps/users/migrations/0011_groupowner.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.4 on 2020-11-24 09:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0009_alter_user_last_name_max_length'), + ('users', '0010_auto_20200828_0823'), + ] + + operations = [ + migrations.CreateModel( + name='GroupOwner', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_groups', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/app/apps/users/migrations/0012_create_group_owners.py b/app/apps/users/migrations/0012_create_group_owners.py new file mode 100644 index 0000000000000000000000000000000000000000..5d560b3f322e72c2f699af34aa819f57bdf7ecab --- /dev/null +++ b/app/apps/users/migrations/0012_create_group_owners.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1.4 on 2020-12-03 11:21 + +from django.db import migrations + + +def forward(apps, se): + Group = apps.get_model('auth', 'Group') + GroupOwner = apps.get_model('users', 'GroupOwner') + + groups_without_owner = Group.objects.filter(groupowner__isnull=True) + for group in groups_without_owner: + GroupOwner.objects.create(group=group, owner=group.user_set.first()) + + +def backward(apps, se): + # no need to do anything + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_groupowner'), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/app/apps/users/models.py b/app/apps/users/models.py index 6f314694260aba6d1b22503b4a958890e7d920b1..91264d718bb07f9c6959c948490a6ef0d460a61b 100644 --- a/app/apps/users/models.py +++ b/app/apps/users/models.py @@ -1,7 +1,6 @@ import os import uuid from datetime import datetime - from django.conf import settings from django.db import models from django.contrib.auth.models import AbstractUser, Group @@ -95,6 +94,29 @@ class Invitation(models.Model): return '%s -> %s' % (self.sender, self.recipient_email) def send(self, request): + if self.recipient and self.group: # already exists in the system + self.send_invitation_to_group(request) + elif self.recipient_email: + self.send_invitation_to_service(request) + else: + # shouldn't happen(?) + pass + + def send_invitation_to_group(self, request): + accept_url = request.build_absolute_uri( + reverse("accept-group-invitation", kwargs={"slug": self.token.hex})) + + context = { + "sender": self.sender.get_full_name() or self.sender.username, + "recipient_first_name": self.recipient.first_name, + "recipient_last_name": self.recipient.last_name, + "recipient_email": self.recipient.email, + "team": self.group.name, + "accept_link": accept_url, + } + self.send_email(self.recipient.email, context) + + def send_invitation_to_service(self, request): accept_url = request.build_absolute_uri( reverse("accept-invitation", kwargs={"token": self.token.hex})) context = { @@ -105,11 +127,14 @@ class Invitation(models.Model): "team": self.group and self.group.name, "accept_link": accept_url, } + self.send_email(self.recipient_email, context) + + def send_email(self, to, context): send_email( 'users/email/invitation_subject.txt', 'users/email/invitation_message.txt', 'users/email/invitation_html.html', - self.recipient_email, + to, context=context, result_interface=('users', 'Invitation', self.id)) @@ -124,6 +149,8 @@ class Invitation(models.Model): self.save() def accept(self, user): + if self.recipient and self.recipient != user: + return False self.recipient = user self.workflow_state = self.STATE_ACCEPTED self.save() @@ -133,6 +160,8 @@ class Invitation(models.Model): _('{username} accepted your invitation!').format( username=self.recipient.username), level='success') + return True + class ContactUs(models.Model): @@ -170,3 +199,16 @@ class ContactUs(models.Model): ) super().save(*args, **kwargs) + + +class GroupOwner(models.Model): + """ + Model for the team to share documents with + the group owner is the first user + """ + group = models.OneToOneField(Group, on_delete=models.CASCADE) + owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL, + related_name='owned_groups') + + def __str__(self): + return str(self.group) diff --git a/app/apps/users/tests.py b/app/apps/users/tests.py index 5f4b27f6757d469768902a1818496e86af798d46..ce6f99e686e4e5f7f615500bc15ce34a7421c48d 100644 --- a/app/apps/users/tests.py +++ b/app/apps/users/tests.py @@ -4,8 +4,7 @@ from django.contrib.auth.models import Group, Permission from django.test import TestCase, override_settings from django.urls import reverse -from users.models import Invitation -from users.models import User as CustomUser, ResearchField +from users.models import Invitation, User as CustomUser, ResearchField, GroupOwner User = get_user_model() @@ -156,3 +155,71 @@ class NotificationTestCase(TestCase): todo https://channels.readthedocs.io/en/latest/topics/testing.html """ pass + + +class TeamTestCase(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="test", + password="test", + email="test@test.com") + + self.invitee = User.objects.create_user(username="test2", + password="test2", + email="test2@test.com") + + self.group = Group.objects.create(name='testgroup') + self.group.user_set.add(self.owner) + GroupOwner.objects.create(group=self.group, owner=self.owner) + + def test_accept(self): + invitation = Invitation.objects.create( + sender=self.owner, + recipient=self.invitee, + group=self.group) + + self.client.force_login(self.invitee) + url = reverse('accept-group-invitation', kwargs={'slug': invitation.token}) + with self.assertNumQueries(9): + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + invitation.refresh_from_db() + self.assertEqual(invitation.workflow_state, Invitation.STATE_ACCEPTED) + + self.assertEqual(self.group.user_set.count(), 2) + + self.invitee.refresh_from_db() + self.assertEqual(self.invitee.groups.count(), 1) + + def test_remove_from_group(self): + + self.group.user_set.add(self.invitee) + self.client.force_login(self.owner) + url = reverse('team-remove-user', kwargs={'pk': self.group.pk}) + with self.assertNumQueries(13): + response = self.client.post(url, data={'user': self.invitee.pk}) + self.assertEqual(response.status_code, 302) + + self.invitee.refresh_from_db() + self.assertEqual(self.invitee.groups.count(), 0) + + def test_leave_group(self): + + self.group.user_set.add(self.invitee) + self.client.force_login(self.invitee) + url = reverse('team-leave', kwargs={'pk': self.group.pk}) + with self.assertNumQueries(4): + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.invitee.groups.count(), 0) + + def test_transfer_ownership(self): + self.group.user_set.add(self.invitee) + self.client.force_login(self.owner) + url = reverse('team-transfer-ownership', kwargs={'pk': self.group.pk}) + + with self.assertNumQueries(7): + response = self.client.post(url, data={'user': self.invitee.pk}) + self.assertEqual(response.status_code, 302) + self.group.groupowner.refresh_from_db() + self.assertEqual(self.group.groupowner.owner, self.invitee) diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py index a90f1c5f2c87036f0154040ff9694d7ebe5d33a3..e3aa844ec11047910b94461f1c68f1593fadcdae 100644 --- a/app/apps/users/urls.py +++ b/app/apps/users/urls.py @@ -1,14 +1,26 @@ from django.urls import path, include -from users.views import SendInvitation, AcceptInvitation, Profile, ContactUsView +from users.views import (SendInvitation, AcceptInvitation, AcceptGroupInvitation, ContactUsView, + ProfileInfos, ProfileGroupListCreate, ProfileApiKey, ProfileFiles, + GroupDetail, RemoveFromGroup, LeaveGroup, TransferGroupOwnership) from django.contrib.auth.decorators import permission_required urlpatterns = [ path('', include('django.contrib.auth.urls')), - path('profile/', Profile.as_view(), name='user_profile'), - path('contact/', ContactUsView.as_view(), name='contactus'), + path('profile/', ProfileInfos.as_view(), name='profile'), + path('profile/apikey/', ProfileApiKey.as_view(), name='profile-api-key'), + path('profile/files/', ProfileFiles.as_view(), name='profile-files'), + path('profile/teams/', ProfileGroupListCreate.as_view(), name='profile-team-list'), + path('teams/<int:pk>/', GroupDetail.as_view(), name='team-detail'), + path('teams/<int:pk>/remove/', RemoveFromGroup.as_view(), name='team-remove-user'), + path('teams/<int:pk>/leave/', LeaveGroup.as_view(), name='team-leave'), + path('teams/<int:pk>/transfer-ownership/', + TransferGroupOwnership.as_view(), + name='team-transfer-ownership'), path('invite/', permission_required('users.can_invite', raise_exception=True)(SendInvitation.as_view()), name='send-invitation'), path('accept/<token>/', AcceptInvitation.as_view(), name='accept-invitation'), + path('accept/group/<slug>/', AcceptGroupInvitation.as_view(), name='accept-group-invitation'), + path('contact/', ContactUsView.as_view(), name='contactus'), ] diff --git a/app/apps/users/views.py b/app/apps/users/views.py index 03a3a9f57d1da9bfd453abf9a740c5641a036261..9eb56f23eeca70d38f8f6f06788949cbd5eb7442 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -3,17 +3,22 @@ from os.path import isfile, join, relpath from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.core.paginator import Paginator -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.utils.functional import cached_property from django.utils.translation import gettext as _ -from django.views.generic.edit import CreateView, UpdateView +from django.urls import reverse +from django.views.generic import CreateView, UpdateView, TemplateView, DetailView +from django.core.exceptions import PermissionDenied from rest_framework.authtoken.models import Token - +from django.contrib.auth.models import Group from users.models import User, Invitation, ContactUs -from users.forms import InvitationForm, InvitationAcceptForm, ProfileForm, ContactUsForm +from users.forms import (InvitationForm, InvitationAcceptForm, ProfileForm, + ContactUsForm, GroupForm, GroupInvitationForm, + RemoveUserFromGroup, TransferGroupOwnershipForm) class SendInvitation(LoginRequiredMixin, SuccessMessageMixin, CreateView): @@ -74,7 +79,75 @@ class AcceptInvitation(CreateView): return response -class Profile(SuccessMessageMixin, UpdateView): +class AcceptGroupInvitation(DetailView): + model = Invitation + slug_field = 'token' + + def get_success_message(self): + return _("You are now a member of Team {team_name}!").format( + team_name=self.object.group.name) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + valid = self.object.accept(self.request.user) + if not valid: + return Http404 + messages.success(self.request, self.get_success_message()) + return HttpResponseRedirect(reverse('profile-team-list')) + + +class GroupOwnerMixin(): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if obj.groupowner.owner != self.request.user: + raise PermissionDenied() # or Http404 + return obj + + def get_success_url(self): + return reverse('team-detail', kwargs={'pk': self.get_object().pk}) + + +class RemoveFromGroup(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Group + form_class = RemoveUserFromGroup + + def get_success_message(self, data): + return _('User {user} successfully removed from the team {team_name}.').format( + user=data.get('user'), + team_name=self.get_object()) + + def form_invalid(self, forms): + return reverse('team-detail', kwargs={'pk': self.get_object().pk}) + + +class LeaveGroup(LoginRequiredMixin, SuccessMessageMixin, DetailView): + model = Group + success_url = '/profile/teams/' + + def get_success_message(self, data): + return _('You successfully left {team}.').format(team=self.object) + + def post(self, request, **kwargs): + self.get_object().user_set.remove(request.user) + return HttpResponseRedirect(reverse('profile-team-list')) + + +class TransferGroupOwnership(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Group + form_class = TransferGroupOwnershipForm + + def get_success_url(self): + return reverse('profile-team-list') + + def get_success_message(self, data): + return _('Successfully transfered ownership to {user}.').format( + user=data.get('user')) + + def form_invalid(self, forms): + return reverse('team-detail', kwargs={'pk': self.get_object().pk}) + + +class ProfileInfos(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = User form_class = ProfileForm success_url = '/profile/' @@ -84,12 +157,24 @@ class Profile(SuccessMessageMixin, UpdateView): def get_object(self): return self.request.user - def get_context_data(self): - context = super().get_context_data() - context['api_auth_token'], created = Token.objects.get_or_create(user=self.object) + +class ProfileApiKey(LoginRequiredMixin, TemplateView): + template_name = 'users/profile_api_key.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['api_auth_token'], created = Token.objects.get_or_create(user=self.request.user) + return context + + +class ProfileFiles(LoginRequiredMixin, TemplateView): + template_name = 'users/profile_files.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) # files directory - upath = self.object.get_document_store_path() + '/' + upath = self.request.user.get_document_store_path() + '/' files = listdir(upath) files.sort(key=lambda x: stat(join(upath, x)).st_mtime) files.reverse() @@ -104,12 +189,52 @@ class Profile(SuccessMessageMixin, UpdateView): return context +class ProfileGroupListCreate(LoginRequiredMixin, SuccessMessageMixin, CreateView): + """ + Both were we create new groups and list them + """ + model = Group + success_url = '/profile/teams/' + success_message = _('Team successfully created.') + template_name = 'users/profile_group_list.html' + form_class = GroupForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_context_data(self): + context = super().get_context_data() + context['invitations'] = Invitation.objects.filter( + recipient=self.request.user, + workflow_state__lt=Invitation.STATE_ACCEPTED) + return context + + +class GroupDetail(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Group + form_class = GroupInvitationForm + success_message = _('User successfully invited to the team.') + template_name = 'users/group_detail_invite.html' + + def get_form_kwargs(self): + # passing instance=None, as the instance would be a group instead of an Invitation + kwargs = super().get_form_kwargs() + kwargs.update({ + 'instance': None, + 'request': self.request}) + return kwargs + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['other_users'] = self.object.user_set.exclude(pk=self.request.user.pk) + return context + + class ContactUsView(SuccessMessageMixin, CreateView): model = ContactUs form_class = ContactUsForm - - success_message = _('Your message successfully sent.') - + success_message = _('Message successfully sent.') template_name = 'users/contactus.html' - success_url = '/contact/' diff --git a/app/escriptorium/static/css/escriptorium.css b/app/escriptorium/static/css/escriptorium.css index 2b060198e5fce09ff82f59fcf1d50cf55c9b3ac5..dba4ea87c941e40d16957e47da125da5e2d0fd3e 100644 --- a/app/escriptorium/static/css/escriptorium.css +++ b/app/escriptorium/static/css/escriptorium.css @@ -27,6 +27,10 @@ section { visibility: hidden; } +.no-margin { + margin: 0; +} + .click-through { pointer-events: none; } @@ -461,7 +465,7 @@ i.panel-icon { } #modal-img-container { - width: 80%; + width: 90%; position: relative; overflow: hidden; } diff --git a/app/escriptorium/static/js/baseline.editor.js b/app/escriptorium/static/js/baseline.editor.js index 83d93059a43049da25326a10df70410789f0c9c0..a74d0c39a7c020f07f8d3e00897f08ce27d379be 100644 --- a/app/escriptorium/static/js/baseline.editor.js +++ b/app/escriptorium/static/js/baseline.editor.js @@ -1455,7 +1455,7 @@ class Segmenter { if (this.idField) context[this.idField] = line[this.idField]; if (!line.baseline) this.toggleMasks(true); return this.createLine(null, line.baseline, line.mask, - region || line.region || null, line.type, context); + region, line.type, context); } else { console.log('EDITOR SKIPING invalid line: ', line); return null; diff --git a/app/escriptorium/templates/base.html b/app/escriptorium/templates/base.html index 4bbf0d95155b88bb5ead3f4df0f514d5f815e54e..9ad9295c1708418597920382eb7d1b70df2cbae0 100644 --- a/app/escriptorium/templates/base.html +++ b/app/escriptorium/templates/base.html @@ -75,8 +75,10 @@ </a> <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown"> <a class="dropdown-item" href="{% url 'password_change' %}">{% trans "Change password" %}</a> - <a class="dropdown-item" href="{% url 'user_profile' %}">{% trans "Profile" %}</a> + + <a class="dropdown-item" href="{% url 'profile' %}">{% trans "Profile" %}</a> <a class="dropdown-item" href="{% url 'report-list' %}">{% trans "Task reports" %}</a> + <div class="dropdown-divider"></div> {% if perms.users.can_invite %}<a class="dropdown-item" href="{% url 'send-invitation' %}">Invite</a>{% endif %} <a class="dropdown-item" href="{% url 'logout' %}">{% trans 'Logout' %}</a> diff --git a/app/escriptorium/templates/core/document_part_edit.html b/app/escriptorium/templates/core/document_part_edit.html index f4f3802b92648e2ecd9c92c8e1185ffe159384b3..b40d1d545b5a20d47d73504d13e7d96ef0f25484 100644 --- a/app/escriptorium/templates/core/document_part_edit.html +++ b/app/escriptorium/templates/core/document_part_edit.html @@ -141,10 +141,25 @@ <div class="col panel"> <div class="tools"> <i title="{% trans 'Source Panel' %}" class="panel-icon fas fa-eye"></i> + <a v-bind:href="part.image.uri" target="_blank"> <button class="btn btn-sm btn-info ml-3 fas fa-download" title="{% trans "Download full size image." %}" download></button> </a> + + <div class="btn-group"> + <button id="rotate-counter-clock" + @click="rotate(360-90)" + title="{% trans "Rotate 90° counter-clockwise." %}" + class="btn btn-sm btn-info ml-3 fas fa-undo" + autocomplete="off">R</button> + <button id="rotate-clock" + @click="rotate(90)" + title="{% trans "Rotate 90° clockwise." %}" + class="btn btn-sm btn-info fas fa-redo" + autocomplete="off">R</button> + </div> + </div> <div class="content-container"> <div id="source-zoom-container" class="content"> diff --git a/app/escriptorium/templates/users/email/invitation_subject.txt b/app/escriptorium/templates/users/email/invitation_subject.txt index 02e7c65314eb44d17d99e4dca8536309081b5d92..7aca19b33a89bb2c9c9ac0d307cb6a3ea025f4f6 100644 --- a/app/escriptorium/templates/users/email/invitation_subject.txt +++ b/app/escriptorium/templates/users/email/invitation_subject.txt @@ -1 +1 @@ -{{sender}} invites you to eScriptorium! +{{sender}} invites you to {% if group %}a Team in {% endif%}eScriptorium! diff --git a/app/escriptorium/templates/users/group_detail_invite.html b/app/escriptorium/templates/users/group_detail_invite.html new file mode 100644 index 0000000000000000000000000000000000000000..681059d166a7b88dc3394ff1bdfa8eecaf189294 --- /dev/null +++ b/app/escriptorium/templates/users/group_detail_invite.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load i18n bootstrap %} + +{% block body %} +<a href="{% url 'profile-team-list' %}">{% trans "Back to profile" %}</a> +<br><br> +<h4>{% trans "Team:" %} {{object}}</h4> + +<form method="post"> + {% trans "Invite a user to this Team." %} + <div class="form-row"> + <div class="col"> + {% csrf_token %} + <input type="hidden" name="group" value="{{object.pk}}"/> + {% render_field form.recipient_id help_text="Make sure to use a correct email as you will not receive an error message if it is not the case (in order to avoid phishing)." %} + </div> + <div class="col-auto"> + <input type="submit" value="{% trans 'Send invitation' %}" title="{% trans "Send invitation" %}" class="btn btn-success btn-block"> + </div> + </div> +</form> + +<h4>{% trans 'Users' %}</h4> +<table class="table table-hover"> + {% for user in other_users %} + <tr> + <td class="col">{{user}}</td> + <td class="col-auto"> + <form method="post" action="{% url 'team-transfer-ownership' object.pk %}"> + {% csrf_token %} + <input type="hidden" name="user" value="{{ user.pk }}"> + <input type="submit" + class="btn btn-warning btn-small" + value="{% trans 'Transfer ownership' %}" + title="{% trans "Transfer ownership" %}"> + </form> + </td> + <td class="col-auto"> + <form method="post" action="{% url 'team-remove-user' object.pk %}"> + {% csrf_token %} + <input type="hidden" name="user" value="{{ user.pk }}"> + <input type="submit" value="{% trans 'Remove' %}" class="btn btn-danger btn-small" title="{% trans "Remove user from group" %}"> + </form> + </td> + </tr> + {% empty %} + {% trans "No other user in this Team." %} + {% endfor %} +</table> +{% endblock %} diff --git a/app/escriptorium/templates/users/profile.html b/app/escriptorium/templates/users/profile.html index 3063f8ed66a5c47ee7aedca92b05f4ecec3ffaf4..7d950acd7edff5bab0ef3acf7f8f1589718b72f6 100644 --- a/app/escriptorium/templates/users/profile.html +++ b/app/escriptorium/templates/users/profile.html @@ -5,13 +5,15 @@ <div class="container"> <div class="row"> <div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical"> - <a class="nav-link active" id="nav-infos-tab" data-toggle="pill" href="#infos-tab" role="tab">{% trans "Informations" %}</a> - <a class="nav-link" id="nav-key-tab" data-toggle="pill" href="#key-tab" role="tab">{% trans "Api key" %}</a> - <a class="nav-link" id="nav-files-tab" data-toggle="pill" href="#files-tab" role="tab">{% trans "Files" %}</a> + <a class="nav-link {% block infos-tab-active %}active{% endblock %}" id="nav-infos-tab" href="{% url 'profile' %}" role="tab">{% trans "Informations" %}</a> + <a class="nav-link {% block key-tab-active %}{% endblock %}" id="nav-key-tab" href="{% url 'profile-api-key' %}" role="tab">{% trans "Api key" %}</a> + <a class="nav-link {% block files-tab-active %}{% endblock %}" id="nav-files-tab" href="{% url 'profile-files' %}" role="tab">{% trans "Files" %}</a> + <a class="nav-link {% block team-tab-active %}{% endblock %}" id="nav-infos-tab" href="{% url 'profile-team-list' %}" role="tab">{% trans "Teams" %}</a> </div> <div class="col-md-8 tab-content" id="v-pills-tabContent"> - <div class="tab-pane fade show active" id="infos-tab" role="tabpanel" aria-labelledby="v-pills-home-tab"> + <div class="tab-pane fade show active" id="infos-tab" role="tabpanel"> + {% block tab-content %} <form method="post"> {% csrf_token %} <fieldset> @@ -22,24 +24,9 @@ <input type="submit" value="{% trans 'Save' %}" class="btn btn-lg btn-success btn-block"> </fieldset> </form> + <button id="reset-onboarding" style="" class="btn btn-lg btn-primary btn-block">Reset onboarding</button> - <button id="reset-onboarding" style="" class="btn btn-lg btn-primary btn-block">Reset onboarding</button> - </div> - - <div class="tab-pane fade show" id="key-tab" role="tabpanel" aria-labelledby="v-pills-home-tab"> - {% trans "API Authentication Token:" %} {{ api_auth_token.key }} - <button class="btn btn-secondary btn-sm fas fa-clipboard float-right" id="api-key-clipboard" title="{% trans "Copy to clipboard." %}" data-key="{{ api_auth_token.key }}"></button> - <br/><span class="text-muted"><small>{% trans "example: "%} $ curl {{request.scheme}}://{{request.get_host}}/api/documents/ -H 'Authorization: Token {{ api_auth_token.key }}'</small></span> - </div> - - <div class="tab-pane fade show" id="files-tab" role="tabpanel" aria-labelledby="v-pills-home-tab"> - {% for fpath, fname in page_obj %} - <br><a href="{{request.scheme}}://{{request.get_host}}{% get_media_prefix %}{{fpath}}"><i class="fas fa-download"></i></a> {{ fname }} - {% empty %} - {% trans "You don't have any saved files yet." %} - {% endfor %} - - {% include "includes/pagination.html" %} + {% endblock %} </div> </div> </div> @@ -54,28 +41,7 @@ navigator.clipboard.writeText($(this).data('key')) }); - function updatePagination(hash) { - $('ul.pagination a').each(function(i, a) { - let href = $(a).attr("href").split('#')[0]; - $(a).attr("href", href+location.hash); - }); - } - - if (location.hash) { - let tab = location.hash.split('#')[1]; - $('#nav-' + tab).tab("show"); - updatePagination(location.hash); - } - $('a[data-toggle="pill"]').on("click", function() { - let url = location.href; - let hash = $(this).attr("href"); - let newUrl = url.split("#")[0] + hash; - history.replaceState(null, null, newUrl); - updatePagination(hash); - }); - $('#reset-onboarding').on('click',function () { - userProfile.set('onboarding_document_form', false); userProfile.set('onboarding_images', false); userProfile.set('onboarding_edit', false); diff --git a/app/escriptorium/templates/users/profile_api_key.html b/app/escriptorium/templates/users/profile_api_key.html new file mode 100644 index 0000000000000000000000000000000000000000..48e1fe1d5cbb66397545879cc9a0fa2acce3d709 --- /dev/null +++ b/app/escriptorium/templates/users/profile_api_key.html @@ -0,0 +1,13 @@ +{% extends "users/profile.html" %} +{% load i18n %} + +{% block infos-tab-active %}{% endblock %} +{% block key-tab-active %}active{% endblock %} +{% block files-tab-active %}{% endblock %} +{% block team-tab-active %}{% endblock %} + +{% block tab-content %} +{% trans "API Authentication Token:" %} {{ api_auth_token.key }} +<button class="btn btn-secondary btn-sm fas fa-clipboard float-right" id="api-key-clipboard" title="{% trans "Copy to clipboard." %}" data-key="{{ api_auth_token.key }}"></button> +<br/><span class="text-muted"><small>{% trans "example: "%} $ curl {{request.scheme}}://{{request.get_host}}/api/documents/ -H 'Authorization: Token {{ api_auth_token.key }}'</small></span> +{% endblock %} diff --git a/app/escriptorium/templates/users/profile_files.html b/app/escriptorium/templates/users/profile_files.html new file mode 100644 index 0000000000000000000000000000000000000000..9849d42183ac7c6a189a0f0ff225190301ccb432 --- /dev/null +++ b/app/escriptorium/templates/users/profile_files.html @@ -0,0 +1,17 @@ +{% extends "users/profile.html" %} +{% load i18n static %} + +{% block infos-tab-active %}{% endblock %} +{% block key-tab-active %}{% endblock %} +{% block files-tab-active %}active{% endblock %} +{% block team-tab-active %}{% endblock %} + +{% block tab-content %} +{% for fpath, fname in page_obj %} +<br><a href="{{request.scheme}}://{{request.get_host}}{% get_media_prefix %}{{fpath}}"><i class="fas fa-download"></i></a> {{ fname }} +{% empty %} +{% trans "You don't have any saved files yet." %} +{% endfor %} + +{% include "includes/pagination.html" %} +{% endblock %} diff --git a/app/escriptorium/templates/users/profile_group_list.html b/app/escriptorium/templates/users/profile_group_list.html new file mode 100644 index 0000000000000000000000000000000000000000..3a7b855f17bd40eab0a437e2a3a1458c85a5ff31 --- /dev/null +++ b/app/escriptorium/templates/users/profile_group_list.html @@ -0,0 +1,63 @@ +{% extends "users/profile.html" %} +{% load i18n bootstrap static %} + +{% block infos-tab-active %}{% endblock %} +{% block key-tab-active %}{% endblock %} +{% block files-tab-active %}{% endblock %} +{% block team-tab-active %}active{% endblock %} + +{% block tab-content %} + +<h4>{% trans "Create a new Team" %}</h4> +<form method="post"> + {% csrf_token %} + <div class="form-row"> + <div class="col">{% render_field form.name group=True %}</div> + <div class="col-auto"><input type="submit" value="{% trans 'Create' %}" class="btn btn-success btn-block col-auto"></div> + </div> + +</form> +<br> + +{% if invitations %} +<h4>{% trans "Pending Invitations" %}</h4> +<table class="table table-hover"> + {% for invitation in invitations %} + <tr> + <td class="col"> + {% blocktrans with team_name=invitation.group.name sender=invitation.sender.username %} + Invited to {{team_name}} by {{sender}} + {% endblocktrans %} + </td> + <td class="col-auto"> + <a href="{% url 'accept-group-invitation' invitation.token %}">{% trans "Accept" %}</a> + </td> + </tr> + {% endfor %} +</table> +<br> +{% endif %} + +<h4>{% trans "My teams" %}</h4> +<table class="table table-hover container"> + {% for team in user.groups.all %} + <tr class="row no-margin"> + {% if team.groupowner.owner == user %} + <td class="col"><a href="{% url 'team-detail' team.pk %}">{{ team }} (owner)</a></td> + <td></td + <td></td> + {% else %} + <td class="col">{{ team }}</td> + <td class="col-auto">{% blocktrans with owner=team.groupowner.owner.username %}Owned by {{owner}}{% endblocktrans %}</td> + <td class="col-auto"> + <form method="post" action="{% url 'team-leave' team.pk %}"> + {% csrf_token %} + <input type="submit" class="btn btn-danger btn-sm" value="{% trans "Leave" %}" /> + </form></td> + {% endif %} + </tr> + {% empty %} + {% trans "You are not part of any team yet." %} + {% endfor %} +</table> +{% endblock %} diff --git a/app/requirements.txt b/app/requirements.txt index 0c77e4a9742166bf645b8aa8e644a482e7557828..8cc8364724daac8118b784f06706f7cad2b55d4e 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -2,18 +2,18 @@ git+https://github.com/celery/kombu.git@4.6.6#egg=kombu git+https://github.com/celery/celery.git@4.4.0rc4#egg=celery Pillow>=5.4.1 -Django==2.1.4 +Django>=2.2,<3 redis==3.2.1 uwsgi==2.0.17 -daphne==2.2.0 # dependency conflict, remove once https://github.com/django/channels/pull/1278 is merged -channels==2.1.7 -channels-redis==2.3.1 +daphne==2.5.0 +channels==2.4.0 +channels-redis==3.2.0 django-redis==4.10.0 psycopg2-binary==2.7.6 django-ordered-model==3.1.1 easy-thumbnails==2.5 git+https://github.com/mittagessen/kraken.git@3.0b17#egg=kraken -django-cleanup==3.0.1 +django-cleanup==5.1.0 djangorestframework==3.9.2 drf-nested-routers==0.91 bleach==3.1.5