Skip to content

Commit

Permalink
Merge pull request #165 from UUDigitalHumanitieslab/feature/project-m…
Browse files Browse the repository at this point in the history
…odel

Feature/project model
  • Loading branch information
lukavdplas authored Jun 20, 2024
2 parents 1336297 + 3aef6b9 commit f7de000
Show file tree
Hide file tree
Showing 18 changed files with 411 additions and 0 deletions.
1 change: 1 addition & 0 deletions backend/edpop/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
'triplestore',
'accounts',
'rdf',
'projects',
]

MIDDLEWARE = [
Expand Down
2 changes: 2 additions & 0 deletions backend/edpop/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
from django.urls import include, path
from django.contrib import admin


urlpatterns = [
path('admin/', admin.site.urls),
path('', include('catalogs.urls')),
path('', include('vre.urls')),
path('', include('accounts.urls')),
path('', include('projects.urls')),
]
Empty file added backend/projects/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions backend/projects/admin.py
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']
27 changes: 27 additions & 0 deletions backend/projects/api.py
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)
13 changes: 13 additions & 0 deletions backend/projects/api_test.py
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
9 changes: 9 additions & 0 deletions backend/projects/apps.py
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
62 changes: 62 additions & 0 deletions backend/projects/conftest.py
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
34 changes: 34 additions & 0 deletions backend/projects/graphs.py
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
29 changes: 29 additions & 0 deletions backend/projects/migrations/0001_initial.py
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.
99 changes: 99 additions & 0 deletions backend/projects/models.py
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()
27 changes: 27 additions & 0 deletions backend/projects/models_test.py
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
8 changes: 8 additions & 0 deletions backend/projects/serializers.py
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']
38 changes: 38 additions & 0 deletions backend/projects/signals.py
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()
Loading

0 comments on commit f7de000

Please sign in to comment.