diff --git a/bin/interactive.py b/bin/interactive.py index 0fcce7dc..6c56807f 100644 --- a/bin/interactive.py +++ b/bin/interactive.py @@ -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, diff --git a/odmf/__init__.py b/odmf/__init__.py index 3484529c..9e6c2a72 100644 --- a/odmf/__init__.py +++ b/odmf/__init__.py @@ -1,2 +1,2 @@ -__version__ = '2023.11.09' +__version__ = '2023.12.13.dev2' prefix = '.' diff --git a/odmf/db/base.py b/odmf/db/base.py index 88af0ff1..ef68d17a 100644 --- a/odmf/db/base.py +++ b/odmf/db/base.py @@ -137,13 +137,17 @@ 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) @@ -151,5 +155,8 @@ def __getitem__(self, item): def __repr__(self): return 'ObjectGetter(' + self.cls.__name__ + ')' + def __iter__(self): + return iter(self.q) + diff --git a/odmf/db/dataset.py b/odmf/db/dataset.py index 1ba87a4f..336783bf 100644 --- a/odmf/db/dataset.py +++ b/odmf/db/dataset.py @@ -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') @@ -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) @@ -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 '' @@ -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: @@ -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 diff --git a/odmf/db/person.py b/odmf/db/person.py index f694166f..b2313fe2 100644 --- a/odmf/db/person.py +++ b/odmf/db/person.py @@ -1,3 +1,5 @@ +import typing + import sqlalchemy as sql import sqlalchemy.orm as orm from functools import total_ordering @@ -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 diff --git a/odmf/db/project.py b/odmf/db/project.py index 966df0ac..5c0adb10 100644 --- a/odmf/db/project.py +++ b/odmf/db/project.py @@ -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 @@ -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( @@ -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 "" % \ @@ -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) + diff --git a/odmf/db/site.py b/odmf/db/site.py index 4fff74d3..f8a0cd64 100644 --- a/odmf/db/site.py +++ b/odmf/db/site.py @@ -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 @@ -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' @@ -85,6 +125,11 @@ 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 @@ -92,6 +137,9 @@ def __lt__(self, 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, diff --git a/odmf/db/sql/migration/2023-11-project-authorization.sql b/odmf/db/sql/migration/2023-11-project-authorization.sql new file mode 100644 index 00000000..91cc983d --- /dev/null +++ b/odmf/db/sql/migration/2023-11-project-authorization.sql @@ -0,0 +1,2 @@ +alter table project add column organization varchar default 'uni-giessen.de'; +alter table project add column sourcelink varchar; \ No newline at end of file diff --git a/odmf/db/timeseries.py b/odmf/db/timeseries.py index 500dda61..0888b039 100644 --- a/odmf/db/timeseries.py +++ b/odmf/db/timeseries.py @@ -220,7 +220,14 @@ def addrecord(self, Id=None, value=None, time=None, comment=None, sample=None, o if (not self.valuetype.inrange(value)): raise ValueError(f'RECORD does not fit VALUETYPE: {value:g} {self.valuetype.unit} is out of ' f'range for {self.valuetype.name}') - if not (self.start <= time <= self.end or out_of_timescope_ok): + + # Check and adjust the timescope of the dataset + if out_of_timescope_ok or None in (self.start, self.end): + + self.start = min(self.start or time, time) + self.end = max(self.end or time, time) + + elif not(self.start <= time <= self.end): raise ValueError( f'RECORD does not fit DATASET: You tried to insert a record for date {time} ' f'to dataset {self}, which allows only records between {self.start} and {self.end}' @@ -229,8 +236,6 @@ def addrecord(self, Id=None, value=None, time=None, comment=None, sample=None, o result = Record(id=Id, time=time, value=value, dataset=self, comment=comment, sample=sample) session.add(result) - self.start = min(self.start, time) - self.end = max(self.end, time) return result def adjusttimespan(self): diff --git a/odmf/plot/plot.py b/odmf/plot/plot.py index 65a3c9eb..4a68b426 100644 --- a/odmf/plot/plot.py +++ b/odmf/plot/plot.py @@ -70,23 +70,26 @@ def generate_name(self): self.subplot.plot.aggregate) return name - def getdatasets(self, session, userlevel=10): + def getdatasets(self, session): """ Loads the datasets for this line """ - + from ..webpage.auth import users + me = users.current datasets = session.query(db.Dataset).filter( db.Dataset._valuetype == self.valuetypeid, db.Dataset._site == self.siteid, db.Dataset.start <= self.subplot.plot.end, - db.Dataset.end >= self.subplot.plot.start, - db.Dataset.access <= userlevel + db.Dataset.end >= self.subplot.plot.start ) if self.instrumentid: datasets = datasets.filter(db.Dataset._source == self.instrumentid) if self.level is not None: datasets = datasets.filter(db.Dataset.level == self.level) - return datasets.order_by(db.Dataset.start).all() + return [ + ds for ds in datasets.order_by(db.Dataset.start) + if ds.get_access_level(me) >= ds.access + ] def load(self, start=None, end=None): """ diff --git a/odmf/static/media/js/dataset-edit.js b/odmf/static/media/js/dataset-edit.js index 09779eab..8051904f 100644 --- a/odmf/static/media/js/dataset-edit.js +++ b/odmf/static/media/js/dataset-edit.js @@ -1,5 +1,5 @@ function loadstatistics(dsid) { - $.getJSON(odmf_ref('/dataset/statistics/'),{id:dsid},function(data){ + $.getJSON(odmf_ref('/dataset/' + dsid + '/statistics/'),{},function(data){ $('#mean').html(data.mean.toPrecision(4)); $('#std').html(data.std.toPrecision(4)); $('#splitthreshold').val(data.std.toPrecision(4)); @@ -166,7 +166,7 @@ $(function() { 'It has ' + btn.data('dssize') + ' records!' var really=confirm(msg) if (really) { - $.post(odmf_ref('/dataset/remove/') + btn.data('dsid'), data => { + $.post('remove', data => { if (data) { $('#error').html(data); $('#error-row').removeClass('d-none'); diff --git a/odmf/static/templates/dataset-edit.html b/odmf/static/templates/dataset-edit.html index 5094185d..2278878a 100644 --- a/odmf/static/templates/dataset-edit.html +++ b/odmf/static/templates/dataset-edit.html @@ -46,41 +46,40 @@