-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #165 from UUDigitalHumanitieslab/feature/project-m…
…odel Feature/project model
- Loading branch information
Showing
18 changed files
with
411 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -59,6 +59,7 @@ | |
'triplestore', | ||
'accounts', | ||
'rdf', | ||
'projects', | ||
] | ||
|
||
MIDDLEWARE = [ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
from django.contrib import admin | ||
from projects import models | ||
|
||
@admin.register(models.Project) | ||
class ProjectAdmin(admin.ModelAdmin): | ||
fieldsets = [ | ||
(None, {'fields': ['name']}), | ||
('Description', {'fields': ['display_name', 'summary']}), | ||
('Access', {'fields': ['public', 'users', 'groups']}), | ||
('RDF', {'fields': ['identifier']}) | ||
] | ||
|
||
filter_horizontal = ['users', 'groups'] | ||
|
||
def get_readonly_fields(self, request, obj): | ||
# make name readonly after creation to prevent breaking IRIs | ||
return ['name', 'identifier'] if obj else ['identifier'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
from rest_framework import viewsets | ||
from django.contrib.auth.models import AnonymousUser | ||
|
||
from projects.models import Project | ||
from projects.serializers import ProjectSerializer | ||
|
||
class ProjectView(viewsets.ReadOnlyModelViewSet): | ||
''' | ||
Read-only endpoint to see available projects | ||
''' | ||
|
||
serializer_class = ProjectSerializer | ||
|
||
def get_queryset(self): | ||
user = self.request.user | ||
|
||
if user.is_superuser: | ||
return Project.objects.all() | ||
|
||
public = Project.objects.filter(public=True) | ||
|
||
if isinstance(user, AnonymousUser): | ||
return public | ||
|
||
direct_access = Project.objects.filter(users=user) | ||
access_through_group = Project.objects.filter(groups__user=user) | ||
return public.union(direct_access, access_through_group) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from django.test import Client | ||
|
||
|
||
def test_project_list_view_anonymous(db, public_project, private_project, client: Client): | ||
response = client.get('/api/projects/') | ||
assert response.status_code == 200 | ||
assert len(response.data) == 1 | ||
|
||
def test_project_list_view_authenticated(db, public_project, private_project, user_1, client: Client): | ||
client.force_login(user_1) | ||
response = client.get('/api/projects/') | ||
assert response.status_code == 200 | ||
assert len(response.data) == 2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class ProjectConfig(AppConfig): | ||
default_auto_field = 'django.db.models.BigAutoField' | ||
name = 'projects' | ||
|
||
def ready(self): | ||
import projects.signals |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
from django.contrib.auth.models import User, Group | ||
import pytest | ||
|
||
from projects.models import Project | ||
|
||
@pytest.fixture() | ||
def group(db): | ||
return Group.objects.create(name='testers') | ||
|
||
|
||
@pytest.fixture() | ||
def user_1(db): | ||
return User.objects.create( | ||
username='tester_added_directly', | ||
password='secret' | ||
) | ||
|
||
|
||
@pytest.fixture() | ||
def user_2(db): | ||
return User.objects.create( | ||
username='tester_not_added', | ||
password='secret' | ||
) | ||
|
||
@pytest.fixture() | ||
def user_3(db, group): | ||
user = User.objects.create( | ||
username='tester_added_through_group', | ||
password='secret' | ||
) | ||
user.groups.add(group) | ||
user.save() | ||
return user | ||
|
||
@pytest.fixture() | ||
def public_project(db, user_1, group): | ||
project = Project.objects.create( | ||
name='public', | ||
display_name='Public', | ||
public=True, | ||
) | ||
project.users.add(user_1) | ||
project.groups.add(group) | ||
project.save() | ||
return project | ||
|
||
|
||
@pytest.fixture() | ||
def private_project(db, user_1, group): | ||
project = Project.objects.create( | ||
name='private', | ||
display_name='Private', | ||
) | ||
project.users.add(user_1) | ||
project.groups.add(group) | ||
project.save() | ||
return project | ||
|
||
@pytest.fixture() | ||
def test_data(group, user_1, user_2, user_3, public_project, private_project): | ||
return |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
from rdflib import RDF, Literal | ||
from triplestore.constants import EDPOPCOL, AS | ||
from itertools import chain | ||
|
||
from projects.models import Project | ||
|
||
def stored_project_metadata(project: Project): | ||
''' | ||
Iterable of project metadata currently in the triplestore. | ||
''' | ||
g = project.graph() | ||
subject = project.identifier() | ||
|
||
type_data = g.triples((subject, RDF.type, None)) | ||
name_data = g.triples((subject, AS.name, None)) | ||
summary_data = g.triples((subject, AS.summary, None)) | ||
|
||
return chain(type_data, name_data, summary_data) | ||
|
||
def project_metadata_to_graph(project: Project): | ||
''' | ||
Graph representation of project metadata. | ||
''' | ||
g = project.graph() | ||
subject = project.identifier() | ||
|
||
g.add((subject, RDF.type, EDPOPCOL.Project)) | ||
|
||
g.add((subject, AS.name, Literal(project.display_name))) | ||
|
||
if project.summary: | ||
g.add((subject, AS.summary, Literal(project.summary))) | ||
|
||
return g |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
# Generated by Django 4.2.13 on 2024-06-14 11:33 | ||
|
||
from django.conf import settings | ||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [ | ||
('auth', '0012_alter_user_first_name_max_length'), | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name='Project', | ||
fields=[ | ||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
('name', models.SlugField(help_text="Identifier of the project; used in IRIs for the project's RDF data", max_length=64, unique=True)), | ||
('display_name', models.CharField(help_text='Human-friendly name for the project', max_length=256)), | ||
('summary', models.TextField(blank=True, help_text='Summary of the project')), | ||
('public', models.BooleanField(default=False, help_text='If true, any visitors can read the RDF data in this project')), | ||
('groups', models.ManyToManyField(blank=True, help_text='User groups with write access to this project; all their members will gain access.', related_name='projects', to='auth.group')), | ||
('users', models.ManyToManyField(blank=True, help_text='Users who can write RDF data in this project', related_name='projects', to=settings.AUTH_USER_MODEL)), | ||
], | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
from django.db import models | ||
from django.contrib.auth.models import User, Group | ||
from django.contrib import admin | ||
from rdflib import URIRef, Graph | ||
from django.conf import settings | ||
|
||
|
||
class Project(models.Model): | ||
''' | ||
A project is a shared endeavour between a group of users (or a single user). | ||
Projects correspond to an RDF graph that contains related collections, annotations, | ||
etc. They represent a scope on which access can be managed. | ||
''' | ||
|
||
name = models.SlugField( | ||
max_length=64, | ||
unique=True, | ||
help_text='Identifier of the project; used in IRIs for the project\'s RDF data', | ||
) | ||
display_name = models.CharField( | ||
max_length=256, | ||
help_text='Human-friendly name for the project', | ||
) | ||
summary = models.TextField( | ||
blank=True, | ||
help_text='Summary of the project', | ||
) | ||
public = models.BooleanField( | ||
default=False, | ||
help_text='If true, any visitors can read the RDF data in this project', | ||
) | ||
users = models.ManyToManyField( | ||
to=User, | ||
blank=True, | ||
related_name='projects', | ||
help_text='Users who can write RDF data in this project', | ||
) | ||
groups = models.ManyToManyField( | ||
to=Group, | ||
blank=True, | ||
related_name='projects', | ||
help_text='User groups with write access to this project; all their members will ' | ||
'gain access.', | ||
) | ||
|
||
|
||
def __str__(self) -> str: | ||
return self.name | ||
|
||
|
||
def graph(self) -> Graph: | ||
''' | ||
RDF graph for the project. | ||
''' | ||
store = settings.RDFLIB_STORE | ||
return Graph(store=store, identifier=self._graph_identifier()) | ||
|
||
|
||
def _graph_identifier(self) -> URIRef: | ||
''' | ||
Identifier for the graph of this project. | ||
''' | ||
return URIRef(settings.RDF_NAMESPACE_ROOT + 'project/' + self.name + '/') | ||
|
||
@admin.display() | ||
def identifier(self) -> URIRef: | ||
''' | ||
Identifier for the subject node of this project. | ||
This is a node within the project graph; it can be used to give context to the | ||
project. | ||
''' | ||
if self.name: | ||
return URIRef(self.name, base=self._graph_identifier()) | ||
|
||
|
||
def permit_query_by(self, user: User) -> bool: | ||
''' | ||
Whether a user should be permitted to make (read-only) queries on the project | ||
graph. | ||
''' | ||
return self.public or user.is_superuser or self._granted_access(user) | ||
|
||
|
||
def permit_update_by(self, user: User) -> bool: | ||
''' | ||
Whether a user should be permitted to make update queries on the project graph. | ||
''' | ||
return user.is_superuser or self._granted_access(user) | ||
|
||
|
||
def _granted_access(self, user: User) -> bool: | ||
''' | ||
Whether a user has been given explicit access, either directly or through a group. | ||
''' | ||
if user.is_anonymous: | ||
return False | ||
return self.users.contains(user) or self.groups.filter(user=user).exists() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import pytest | ||
from django.contrib.auth.models import User, AnonymousUser | ||
|
||
from projects.models import Project | ||
|
||
@pytest.mark.parametrize('project_name,username,can_query,can_update', [ | ||
('public', 'tester_added_directly', True, True), | ||
('public', 'tester_not_added', True, False), | ||
('public', 'tester_added_through_group', True, True), | ||
('public', None, True, False), | ||
('private', 'tester_added_directly', True, True), | ||
('private', 'tester_not_added', False, False), | ||
('private', 'tester_added_through_group', True, True), | ||
('private', None, False, False) | ||
]) | ||
def test_project_access( | ||
test_data, project_name, username, can_query, can_update | ||
): | ||
project = Project.objects.get(name=project_name) | ||
|
||
if username: | ||
user = User.objects.get(username=username) | ||
else: | ||
user = AnonymousUser() | ||
|
||
assert project.permit_query_by(user) == can_query | ||
assert project.permit_update_by(user) == can_update |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
from rest_framework import serializers | ||
|
||
from projects.models import Project | ||
|
||
class ProjectSerializer(serializers.ModelSerializer): | ||
class Meta: | ||
model = Project | ||
fields = ['name', 'display_name', 'summary'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
from django.db.models.signals import post_save, post_delete | ||
from django.dispatch import receiver | ||
from django.conf import settings | ||
from rdf.utils import prune_triples | ||
|
||
from projects.models import Project | ||
from projects.graphs import ( | ||
stored_project_metadata, project_metadata_to_graph | ||
) | ||
from triplestore.utils import triples_to_quads, all_triples | ||
|
||
@receiver(post_save, sender=Project) | ||
def store_project_graph(sender, instance: Project, created, **kwargs): | ||
''' | ||
Store project metadata in the triplestore. | ||
''' | ||
store = settings.RDFLIB_STORE | ||
g = instance.graph() | ||
|
||
if not created: | ||
prune_triples(g, stored_project_metadata(instance)) | ||
|
||
triples = all_triples(project_metadata_to_graph(instance)) | ||
quads = triples_to_quads(triples, g) | ||
store.addN(quads) | ||
store.commit() | ||
|
||
|
||
@receiver(post_delete, sender=Project) | ||
def delete_project_graph(sender, instance, **kwargs): | ||
''' | ||
Delete all data in a project graph | ||
''' | ||
|
||
store = settings.RDFLIB_STORE | ||
g = instance.graph() | ||
prune_triples(g, all_triples(g)) | ||
store.commit() |
Oops, something went wrong.