Skip to content

Commit 59b8564

Browse files
authored
Merge pull request doccano#333 from CatalystCode/feature/roles
Feature/roles
2 parents 843ba42 + 2066a61 commit 59b8564

28 files changed

+1066
-140
lines changed

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ Next we need to create a user who can login to the admin site. Run the following
150150
python manage.py create_admin --noinput --username "admin" --email "[email protected]" --password "password"
151151
```
152152

153+
Create the admin, annotator, and annotation approver roles to assign to users. Run the following command:
154+
155+
```bash
156+
python manage.py create_roles
157+
```
158+
153159
Developers can also validate that the project works as expected by running the tests:
154160

155161
```bash
@@ -184,7 +190,7 @@ Now, open a Web browser and go to <http://127.0.0.1:8000/login/>. You should see
184190

185191
Now, try logging in with the superuser account you created in the previous step. You should see the doccano project list page:
186192

187-
<img src="./docs/projects.png" alt="projects" width=600>
193+
<img src="./docs/projects.png" alt="Projects page" width=600>
188194

189195
There is no project created yet. To create your project, make sure you’re in the project list page and select `Create Project` button. You should see the following screen:
190196

@@ -235,17 +241,23 @@ Click `Labels` button in left bar to define your own labels. You should see the
235241

236242
<img src="./docs/label_editor.png" alt="Edit label" width=600>
237243

244+
### Assign Roles to Users
245+
246+
Click `Users` button in left bar to assign project users to annotator, admin, or annotation approval roles.
247+
248+
<img src="./docs/user_page.png" alt="Assign users to roles on project" width=600>
249+
238250
### Annotation
239251

240252
Now, you are ready to annotate the texts. Just click the `Annotate Data` button in the navigation bar, you can start to annotate the documents you uploaded.
241253

242-
<img src="./docs/annotation.png" alt="Edit label" width=600>
254+
<img src="./docs/annotation.png" alt="Annotate data" width=600>
243255

244256
### Export Data
245257

246258
After the annotation step, you can download the annotated data. Click the `Edit data` button in navigation bar, and then click `Export Data`. You should see below screen:
247259

248-
<img src="./docs/export_data.png" alt="Edit label" width=600>
260+
<img src="./docs/export_data.png" alt="Export data" width=600>
249261

250262
You can export data as CSV file or JSON file by clicking the button. As for the export file format, you can check it here: [Export File Formats](https://github.com/chakki-works/doccano/wiki/Export-File-Formats).
251263

app/api/admin.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin
22

33
from .models import Label, Document, Project
4+
from .models import Role, RoleMapping
45
from .models import DocumentAnnotation, SequenceAnnotation, Seq2seqAnnotation
56
from .models import TextClassificationProject, SequenceLabelingProject, Seq2seqProject
67

@@ -41,6 +42,18 @@ class Seq2seqAnnotationAdmin(admin.ModelAdmin):
4142
search_fields = ('document',)
4243

4344

45+
class RoleAdmin(admin.ModelAdmin):
46+
list_display = ('name', 'description')
47+
ordering = ('name',)
48+
search_fields = ('name',)
49+
50+
51+
class RoleMappingAdmin(admin.ModelAdmin):
52+
list_display = ('user', 'role', 'project', )
53+
ordering = ('user',)
54+
search_fields = ('user',)
55+
56+
4457
admin.site.register(DocumentAnnotation, DocumentAnnotationAdmin)
4558
admin.site.register(SequenceAnnotation, SequenceAnnotationAdmin)
4659
admin.site.register(Seq2seqAnnotation, Seq2seqAnnotationAdmin)
@@ -50,3 +63,5 @@ class Seq2seqAnnotationAdmin(admin.ModelAdmin):
5063
admin.site.register(TextClassificationProject, ProjectAdmin)
5164
admin.site.register(SequenceLabelingProject, ProjectAdmin)
5265
admin.site.register(Seq2seqProject, ProjectAdmin)
66+
admin.site.register(Role, RoleAdmin)
67+
admin.site.register(RoleMapping, RoleMappingAdmin)

app/api/migrations/0004_roles.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 2.1.7 on 2019-07-25 03:33
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('api', '0003_support_sql_server'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Role',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('name', models.CharField(max_length=100, unique=True)),
21+
('description', models.TextField(default='', null=True)),
22+
('created_at', models.DateTimeField(auto_now_add=True)),
23+
('updated_at', models.DateTimeField(auto_now=True)),
24+
],
25+
),
26+
migrations.CreateModel(
27+
name='RoleMapping',
28+
fields=[
29+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
30+
('created_at', models.DateTimeField(auto_now_add=True)),
31+
('updated_at', models.DateTimeField(auto_now=True)),
32+
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
33+
related_name='role_mappings', to='api.Project')),
34+
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.Role')),
35+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
36+
related_name='role_mappings', to=settings.AUTH_USER_MODEL)),
37+
],
38+
),
39+
migrations.AlterUniqueTogether(
40+
name='rolemapping',
41+
unique_together={('user', 'project', 'role')},
42+
),
43+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 2.1.7 on 2019-10-21 15:48
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0004_project_collaborative_annotation'),
10+
('api', '0004_roles'),
11+
]
12+
13+
operations = [
14+
]

app/api/models.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import string
22

33
from django.db import models
4+
from django.dispatch import receiver
5+
from django.db.models.signals import post_save, pre_delete
46
from django.urls import reverse
7+
from django.conf import settings
58
from django.contrib.auth.models import User
69
from django.contrib.staticfiles.storage import staticfiles_storage
710
from django.core.exceptions import ValidationError
@@ -237,3 +240,81 @@ class Seq2seqAnnotation(Annotation):
237240

238241
class Meta:
239242
unique_together = ('document', 'user', 'text')
243+
244+
245+
class Role(models.Model):
246+
name = models.CharField(max_length=100, unique=True)
247+
description = models.TextField(default='')
248+
created_at = models.DateTimeField(auto_now_add=True)
249+
updated_at = models.DateTimeField(auto_now=True)
250+
251+
def __str__(self):
252+
return self.name
253+
254+
255+
class RoleMapping(models.Model):
256+
user = models.ForeignKey(User, related_name='role_mappings', on_delete=models.CASCADE)
257+
project = models.ForeignKey(Project, related_name='role_mappings', on_delete=models.CASCADE)
258+
role = models.ForeignKey(Role, on_delete=models.CASCADE)
259+
created_at = models.DateTimeField(auto_now_add=True)
260+
updated_at = models.DateTimeField(auto_now=True)
261+
262+
def clean(self):
263+
other_rolemappings = self.project.role_mappings.exclude(id=self.id)
264+
265+
if other_rolemappings.filter(user=self.user, project=self.project).exists():
266+
raise ValidationError('This user is already assigned to a role in this project.')
267+
268+
class Meta:
269+
unique_together = ("user", "project", "role")
270+
271+
272+
@receiver(post_save, sender=RoleMapping)
273+
def add_linked_project(sender, instance, created, **kwargs):
274+
if not created:
275+
return
276+
userInstance = instance.user
277+
projectInstance = instance.project
278+
if userInstance and projectInstance:
279+
user = User.objects.get(pk=userInstance.pk)
280+
project = Project.objects.get(pk=projectInstance.pk)
281+
user.projects.add(project)
282+
user.save()
283+
284+
285+
@receiver(post_save)
286+
def add_superusers_to_project(sender, instance, created, **kwargs):
287+
if not created:
288+
return
289+
if sender not in Project.__subclasses__():
290+
return
291+
superusers = User.objects.filter(is_superuser=True)
292+
admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
293+
if superusers and admin_role:
294+
RoleMapping.objects.bulk_create(
295+
[RoleMapping(role_id=admin_role.id, user_id=superuser.id, project_id=instance.id)
296+
for superuser in superusers]
297+
)
298+
299+
300+
@receiver(post_save, sender=User)
301+
def add_new_superuser_to_projects(sender, instance, created, **kwargs):
302+
if created and instance.is_superuser:
303+
admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
304+
projects = Project.objects.all()
305+
if admin_role and projects:
306+
RoleMapping.objects.bulk_create(
307+
[RoleMapping(role_id=admin_role.id, user_id=instance.id, project_id=project.id)
308+
for project in projects]
309+
)
310+
311+
312+
@receiver(pre_delete, sender=RoleMapping)
313+
def delete_linked_project(sender, instance, using, **kwargs):
314+
userInstance = instance.user
315+
projectInstance = instance.project
316+
if userInstance and projectInstance:
317+
user = User.objects.get(pk=userInstance.pk)
318+
project = Project.objects.get(pk=projectInstance.pk)
319+
user.projects.remove(project)
320+
user.save()

app/api/permissions.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1+
from django.conf import settings
12
from django.contrib.auth.mixins import UserPassesTestMixin
3+
from django.db.models import Subquery
24
from django.shortcuts import get_object_or_404
35
from rest_framework.permissions import BasePermission, SAFE_METHODS, IsAdminUser
46

5-
from .models import Project
7+
from .models import Project, Role, RoleMapping
68

79

8-
class IsProjectUser(BasePermission):
9-
10-
def has_permission(self, request, view):
11-
user = request.user
12-
project_id = view.kwargs.get('project_id') or request.query_params.get('project_id')
13-
project = get_object_or_404(Project, pk=project_id)
14-
15-
return user in project.users.all()
10+
class ProjectMixin:
11+
@classmethod
12+
def get_project_id(self, request, view):
13+
return view.kwargs.get('project_id') or request.query_params.get('project_id')
1614

1715

1816
class IsAdminUserAndWriteOnly(BasePermission):
@@ -24,19 +22,72 @@ def has_permission(self, request, view):
2422
return IsAdminUser().has_permission(request, view)
2523

2624

27-
class SuperUserMixin(UserPassesTestMixin):
28-
25+
class ProjectAdminMixin(UserPassesTestMixin):
2926
def test_func(self):
30-
return self.request.user.is_superuser
27+
return self.request.user.is_superuser or is_in_role(
28+
role_name=IsProjectAdmin.role_name,
29+
user_id=self.request.user.id,
30+
project_id=self.kwargs['project_id'],
31+
)
3132

3233

33-
class IsOwnAnnotation(BasePermission):
34+
class IsOwnAnnotation(ProjectMixin, BasePermission):
3435

3536
def has_permission(self, request, view):
36-
project_id = view.kwargs.get('project_id')
37+
project_id = self.get_project_id(request, view)
3738
annotation_id = view.kwargs.get('annotation_id')
3839
project = get_object_or_404(Project, pk=project_id)
3940
model = project.get_annotation_class()
4041
annotation = model.objects.filter(id=annotation_id, user=request.user)
4142

4243
return annotation.exists()
44+
45+
46+
class RolePermission(ProjectMixin, BasePermission):
47+
UNSAFE_METHODS = ('POST', 'PATCH', 'DELETE')
48+
unsafe_methods_check = True
49+
role_name = ''
50+
51+
def has_permission(self, request, view):
52+
if request.user.is_superuser:
53+
return True
54+
55+
if self.unsafe_methods_check and request.method in self.UNSAFE_METHODS:
56+
return request.user.is_superuser
57+
58+
project_id = self.get_project_id(request, view)
59+
if not project_id and request.method in SAFE_METHODS:
60+
return True
61+
62+
return is_in_role(self.role_name, request.user.id, project_id)
63+
64+
65+
class IsProjectAdmin(RolePermission):
66+
unsafe_methods_check = False
67+
role_name = settings.ROLE_PROJECT_ADMIN
68+
69+
70+
class IsAnnotatorAndReadOnly(RolePermission):
71+
role_name = settings.ROLE_ANNOTATOR
72+
73+
74+
class IsAnnotator(RolePermission):
75+
unsafe_methods_check = False
76+
role_name = settings.ROLE_ANNOTATOR
77+
78+
79+
class IsAnnotationApproverAndReadOnly(RolePermission):
80+
role_name = settings.ROLE_ANNOTATION_APPROVER
81+
82+
83+
class IsAnnotationApprover(RolePermission):
84+
unsafe_methods_check = False
85+
role_name = settings.ROLE_ANNOTATION_APPROVER
86+
87+
88+
def is_in_role(role_name, user_id, project_id):
89+
return RoleMapping.objects.filter(
90+
user_id=user_id,
91+
project_id=project_id,
92+
role_id=Subquery(Role.objects.filter(name=role_name).values('id')),
93+
).exists()

0 commit comments

Comments
 (0)