Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project Authorization #91

Merged
merged 34 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0693a3d
Extend tools.Path to work well togehter with pathlib.Path
Mar 15, 2021
c276dae
Improved interactive mode with some syntactic sugar
Mar 15, 2021
c54eb30
Added project membership, create with Base.metadata.create_all(engine)
Mar 15, 2021
a80b0ef
Added project membership to auth module and created a file access ru…
Mar 15, 2021
039420e
Using file access control via a hidden file for static serving and
Mar 15, 2021
bb9090e
Improved web.HTTPError output
Mar 15, 2021
45abb8a
Unloaded overlong dbobjects.py
Jun 1, 2021
dc0b7f8
Merge branch 'issue_78' into i71_project_page
Jun 1, 2021
d913c56
Improved interactive.py
Jun 1, 2021
0e7833f
Merge branch 'master' into project_authorization
Jul 16, 2021
e7103e7
Merge branch 'new_installation' into project_authorization
Jul 16, 2021
1f6771a
Testing database connection in github ci
Jul 20, 2021
ba59637
Update python-app.yml
philippkraft Jul 21, 2021
1844fe2
Merge branch 'master' into project_authorization
Jul 23, 2021
8fec56a
Mostly working project page (html)
Jul 23, 2021
08a3e73
Merge branch 'new_installation' into project_authorization
Feb 14, 2022
6195a36
Merge remote-tracking branch 'origin/project_authorization' into proj…
Feb 14, 2022
8de8317
Added a prototype to store alternative geometries for sites #3
May 10, 2022
7931310
Merge branch 'master' into project_authorization
Nov 6, 2023
5ccd7f1
A first try to add members to projects, tests not running
Nov 8, 2023
88336ef
Merge branch 'master' into project_authorization
Nov 22, 2023
1cca4f6
#90 - Working tests for n:n person<->project relationship
Nov 24, 2023
f955fd2
#90 - Improving the project page
Nov 24, 2023
83d465e
#90 - Project page working
Nov 27, 2023
5b19b91
#90 - File access by project working, introduced Level-Enum for autho…
Nov 28, 2023
3ef6c9e
Added a database migration tool
Nov 30, 2023
302b2c9
By project authorization protected dataset page
Dec 4, 2023
3e9c015
More project page and authorization handling
Dec 4, 2023
32e677e
Filter datasetlist by project and show usage level for dataset
Dec 5, 2023
51a922a
Added projects to person page and removed obsolete functions from aut…
Dec 5, 2023
f0fdbec
Fixed tests and resolve deprecation issue for PIL
Dec 5, 2023
1bf9fde
Blocked access on project basis in plot and API
Dec 8, 2023
1cd5202
Merge branch 'master' into project_authorization
Dec 12, 2023
2d5518a
Added project to dataset description and improved project list page
Dec 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import os
import time
import numpy as np
import time
import contextlib

import time
import logging
logging.basicConfig(
level=logging.INFO,
Expand Down
2 changes: 1 addition & 1 deletion odmf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = '2023.11.09'
__version__ = '2023.12.13.dev2'
prefix = '.'
11 changes: 9 additions & 2 deletions odmf/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,26 @@ class ObjectGetter:
>>> print(ds[10])
>>> ds.q.filter_by(measured_by='philipp')
"""
def __init__(self, cls: type, session: orm.Session):
def __init__(self, cls: type, session: orm.Session, **filter):
self.cls = cls
self.session = session
self.filter = filter

def __repr__(self):
return f'db.{self.cls.__name__}[...]'

@property
def q(self) -> orm.Query:
return self.session.query(self.cls)
return self.session.query(self.cls).filter_by(**self.filter)

def __getitem__(self, item):
return self.q.get(item)

def __repr__(self):
return 'ObjectGetter(' + self.cls.__name__ + ')'

def __iter__(self):
return iter(self.q)



52 changes: 41 additions & 11 deletions odmf/db/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

from .base import Base, session_scope
from ..config import conf
from ..webpage import auth
from typing import Optional
from logging import getLogger

logger = getLogger(__name__)

tzberlin = pytz.timezone('Europe/Berlin')
Expand Down Expand Up @@ -131,6 +133,13 @@ class Dataset(Base):
_source = sql.Column("source", sql.Integer, sql.ForeignKey(
'datasource.id'), nullable=True)
source = orm.relationship("Datasource", backref="datasets")
access = sql.Column(sql.Integer, default=1, nullable=False)
timezone = sql.Column(sql.String,
default=conf.datetime_default_timezone)
_project = sql.Column('project', sql.Integer, sql.ForeignKey('project.id'),
nullable=True)
project = sql.orm.relationship('Project', back_populates='datasets')

calibration_offset = sql.Column(sql.Float, nullable=False, default=0.0)
calibration_slope = sql.Column(sql.Float, nullable=False, default=1.0)
comment = sql.Column(sql.String)
Expand All @@ -140,12 +149,8 @@ class Dataset(Base):
uses_dst = sql.Column(sql.Boolean, default=False, nullable=False)
__mapper_args__ = dict(polymorphic_identity=None,
polymorphic_on=type)
access = sql.Column(sql.Integer, default=1, nullable=False)

timezone = sql.Column(sql.String,
default=conf.datetime_default_timezone)
project = sql.Column(sql.Integer, sql.ForeignKey('project.id'),
nullable=True)


def __str__(self):
site = self.site.id if self.site else ''
Expand Down Expand Up @@ -240,17 +245,17 @@ def path(self):

@classmethod
def filter(
cls, session,
valuetype: Optional[int]=None,
cls, session,
valuetype: Optional[int]=None,
user: Optional[str]=None,
site: Optional[int]=None,
date: Optional[datetime.datetime]=None,
site: Optional[int]=None,
date: Optional[datetime.datetime]=None,
instrument: Optional[int]=None,
type: Optional[str]=None,
type: Optional[str]=None,
level: Optional[float]=None
) -> orm.Query:
"""
Filters datasets for fitting properties
Filters datasets for fitting properties
"""
datasets: orm.Query = session.query(cls)
if user:
Expand All @@ -272,6 +277,31 @@ def filter(
datasets = datasets.filter_by(level=level)
return datasets

def get_access_level(self, user: auth.User) -> auth.Level:
"""
Returns the access level of a user for this dataset, depending on the users access level,
if the datasets project is a project of the user or if the user is the owner.

Site admins, the owner (measured_by) and admins of this datasets project are admins for the dataset.
Lower levels are defined by the project. If the dataset does not belong to a project, the access level to
the dataset is the access level of the user

:param user:
:return:
"""
if user.level >= auth.Level.admin:
return user.level
elif user.name == self.measured_by.username:
return auth.Level.admin
elif self._project is None:
return user.level
elif self._project in user.projects:
return user.projects[self._project]
else:
return auth.Level.guest

def is_defined(self) -> bool:
return bool(self.measured_by and self.site and self.valuetype)

def removedataset(*args):
"""Removes a dataset and its records entirely from the database
Expand Down
26 changes: 26 additions & 0 deletions odmf/db/person.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

import sqlalchemy as sql
import sqlalchemy.orm as orm
from functools import total_ordering
Expand Down Expand Up @@ -49,3 +51,27 @@ def __lt__(self, other):
return NotImplemented
return self.surname < str(other)
return self.surname < other.surname

def projects(self):
"""
Yields Project, access_level tuples
:return:
"""
from .project import ProjectMember, Project
from ..webpage.auth import Level
pm: ProjectMember
for pm in (
self.session().query(ProjectMember)
.filter(ProjectMember.member == self)
.order_by(ProjectMember.access_level.desc(), ProjectMember._member)
):
yield pm.project, Level(pm.access_level)
for project in self.session().query(Project).filter(Project.person_responsible == self):
yield project, Level.admin


def add_project(self, project, access_level: int=0):
from .project import ProjectMember
pm = ProjectMember(member=self, project=project, access_level=access_level)
self.session().add(pm)
return pm
97 changes: 90 additions & 7 deletions odmf/db/project.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sqlalchemy as sql
import sqlalchemy.orm as orm
from .base import Base

from .person import Person
from functools import total_ordering

from logging import getLogger
Expand All @@ -15,7 +15,7 @@ class Project(Base):
"""
__tablename__ = 'project'

id = sql.Column(sql.Integer, primary_key=True)
id = sql.Column(sql.Integer, primary_key=True, autoincrement=True)
_person_responsible = sql.Column('person_responsible', sql.String,
sql.ForeignKey('person.username'))
person_responsible = sql.orm.relationship(
Expand All @@ -25,10 +25,79 @@ class Project(Base):
)
name = sql.Column(sql.String)
comment = sql.Column(sql.String)
sourcelink = sql.Column(sql.String)
organization = sql.Column(sql.String, default='uni-giessen.de')
datasets = sql.orm.relationship('Dataset')

@property
def members_query(self):
"""Returns a query object with all ProjectMember object related to this project"""
return self.session().query(ProjectMember).filter(ProjectMember._project==self.id)
def members(self, access_level=0):
from ..webpage.auth import Level
for pm in (
self.members_query.filter(ProjectMember.access_level>=access_level)
.order_by(ProjectMember.access_level.desc(), ProjectMember._member)
):
yield pm.member, Level(pm.access_level)

if self.person_responsible:
yield self.person_responsible, Level.admin

def add_member(self, person: Person|str, access_level: int=0):
from ..webpage.auth import Level
if not type(person) is Person:
person = Person.get(self.session(), person)
if person.access_level < Level.editor and access_level >= Level.editor:
raise ValueError(f'Cannot give {person} who is not a global editor editing rights for a project')
if pm:=self[person]:
pm.access_level = access_level
else:
pm = ProjectMember(member=person, project=self, access_level=access_level)
self.session().add(pm)
return pm

def remove_member(self, username: Person|str):
if type(username) is Person:
username = username.username
n=self.members_query.filter_by(_member=username).delete()
if not n:
raise ValueError(f'{username} was not a member of {self}, cannot remove')

def __getitem__(self, username):
if type(username) is Person:
username = username.username
return self.members_query.filter_by(_member=username).first()


def get_access_level(self, user: Person|str):
"""
Returns the level a given user has in context of this project

Site admins and project spokepersons evaluate as Level.admin, every other project member
with their level as saved in the members list (Level.logger...Level.admin). Users who are not
members of the project and not site admins evaluate as Level.guest

:param user: A db.Person object or a username
:return: the Level
"""
from ..webpage.auth import Level
if not type(user) is Person:
user = Person.get(self.session(), user)

if user == self.person_responsible:
return Level.admin
elif user.access_level >= Level.admin:
return Level(user.access_level)
for member, level in self.members():
if member == user:
return Level(level)
else:
return Level.guest


def __str__(self):
return " %s %s: %s %s" % (self.id, self.name, self.person_responsible,
self.comment)
return self.name

def __repr__(self):
return "<Project(id=%s, name=%s, person=%s)>" % \
Expand All @@ -50,6 +119,20 @@ def __jdict__(self):
person_responsible=self.person_responsible,
comment=self.comment)

# Creating all tables those inherit Base
# print "Create Tables"
# Base.metadata.create_all(engine)

class ProjectMember(Base):
"""
n:n Association object between projects and their members.

TODO: Write tests
"""
__tablename__ = 'project_member'

_project = sql.Column('project', sql.ForeignKey('project.id', ondelete='CASCADE'), primary_key=True)
_member = sql.Column('member', sql.ForeignKey('person.username', ondelete='CASCADE'), primary_key=True)

project = sql.orm.relationship('Project')
member = sql.orm.relationship('Person')

access_level = sql.Column(sql.Integer, nullable=False, default=0)

48 changes: 48 additions & 0 deletions odmf/db/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def __jdict__(self):
comment=self.comment,
icon=self.icon)

def __eq__(self, other):
if hasattr(other, 'id'):
return NotImplemented
return self.id == other.id

def __lt__(self, other):
if not hasattr(other, 'id'):
return NotImplemented
return self.id < other.id

def __hash__(self):
return hash(str(self))

def as_UTM(self):
"""Returns a tuple (x,y) as UTM/WGS84 of the site position
If withzone is True it returns the name of the UTM zone as a third argument
Expand All @@ -66,6 +79,33 @@ def as_coordinatetext(self):
return ("%i° %i' %0.2f''N - " % lat) + ("%i° %i' %0.2f''E" % lon)


@total_ordering
class SiteGeometry(Base):
"""
Enhance a site with Geometry features, like line or polygon
TODO: sqlalchemy: Do we need polymorphic_identity
TODO: Implement __geo_interface__ for Site and GeometrySite

Uses GeoJSON definition to save the geometry of a site
https://de.wikipedia.org/wiki/GeoJSON
"""
__tablename__ = 'site_geometry'

id = sql.Column(sql.Integer, sql.ForeignKey('site.id'), primary_key=True)
site = orm.relationship('Site', backref=orm.backref('geometry'),
primaryjoin="Site.id==SiteGeometry.id")
type = sql.Column(sql.Text) # Contains the GeoJSON geometry type: POINT, POLYGON, MULTILINESTRING etc.
coordinates = sql.Column(sql.Text) # Contains coordinates in GeoJSON notation

strokewidth = sql.Column(sql.Float)
strokeopacity = sql.Column(sql.Float)
strokecolor = sql.Column(sql.Text)
fillcolor = sql.Column(sql.Text)
fillopacity = sql.Column(sql.Float)




@total_ordering
class Datasource(Base):
__tablename__ = 'datasource'
Expand All @@ -85,13 +125,21 @@ def linkname(self):
def __str__(self):
return '%s (%s)' % (self.name, self.sourcetype)

def __eq__(self, other):
if not hasattr(other, 'name'):
return NotImplemented
return self.name == other.name

def __lt__(self, other):
if not hasattr(other, 'name'):
return NotImplemented
elif other:
return self.name < other.name
return False

def __hash__(self):
return hash(str(self))

def __jdict__(self):
return dict(id=self.id,
name=self.name,
Expand Down
2 changes: 2 additions & 0 deletions odmf/db/sql/migration/2023-11-project-authorization.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table project add column organization varchar default 'uni-giessen.de';
alter table project add column sourcelink varchar;
Loading