diff options
| -rw-r--r-- | server/config.yaml.dist | 5 | ||||
| -rw-r--r-- | server/szurubooru/api/post_api.py | 6 | ||||
| -rw-r--r-- | server/szurubooru/func/metrics.py | 84 | ||||
| -rw-r--r-- | server/szurubooru/model/metric.py | 6 | ||||
| -rw-r--r-- | server/szurubooru/tests/conftest.py | 22 | ||||
| -rw-r--r-- | server/szurubooru/tests/func/test_metrics.py | 107 | ||||
| -rw-r--r-- | server/szurubooru/tests/model/test_metric.py | 50 |
7 files changed, 259 insertions, 21 deletions
diff --git a/server/config.yaml.dist b/server/config.yaml.dist index a68aea3..f180f34 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -117,8 +117,9 @@ privileges: 'tag_categories:delete': moderator 'tag_categories:set_default': moderator - 'metrics:create': moderator - 'metrics:edit:bounds': moderator + 'metrics:create': power + 'metrics:edit:bounds': power + 'metrics:edit:posts': regular 'comments:create': regular 'comments:delete:any': moderator diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index 58d0708..914b70b 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -2,7 +2,7 @@ from typing import Optional, Dict, List from datetime import datetime from szurubooru import db, model, errors, rest, search from szurubooru.func import ( - auth, tags, posts, snapshots, favorites, scores, serialization, versions) + auth, tags, posts, snapshots, favorites, scores, serialization, versions, metrics) _search_executor_config = search.configs.PostSearchConfig() @@ -135,6 +135,10 @@ def update_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: if ctx.has_file('thumbnail'): auth.verify_privilege(ctx.user, 'posts:edit:thumbnail') posts.update_post_thumbnail(post, ctx.get_file('thumbnail')) + if ctx.has_file('metrics'): + auth.verify_privilege(ctx.user, 'metrics:edit:posts') + metrics.update_or_create_post_metrics(post, ctx.get_param_as_list('metrics')) + post.last_edit_time = datetime.utcnow() ctx.session.flush() snapshots.modify(post, ctx.user) diff --git a/server/szurubooru/func/metrics.py b/server/szurubooru/func/metrics.py index ffb8906..e1c5e67 100644 --- a/server/szurubooru/func/metrics.py +++ b/server/szurubooru/func/metrics.py @@ -1,7 +1,10 @@ -from typing import Any, Optional, Tuple, List, Dict, Callable -import sqlalchemy as sa -from szurubooru import config, db, model, errors, rest -from szurubooru.func import serialization +from typing import Any, Optional, List, Dict, Callable +from szurubooru import db, model, errors, rest +from szurubooru.func import serialization, tags + + +class MetricDoesNotExistsError(errors.ValidationError): + pass class MetricAlreadyExistsError(errors.ValidationError): @@ -12,7 +15,15 @@ class InvalidMetricError(errors.ValidationError): pass -class MetricSeralizer(serialization.BaseSerializer): +class PostMissingTagError(errors.ValidationError): + pass + + +class MetricValueOutOfRangeError(errors.ValidationError): + pass + + +class MetricSerializer(serialization.BaseSerializer): def __init__(self, metric: model.Metric): self.metric = metric @@ -29,16 +40,30 @@ class MetricSeralizer(serialization.BaseSerializer): return self.metric.max -def serialize_metric(metric: model.Metric, options: List[str] = []) -> Optional[rest.Response]: +def serialize_metric( + metric: model.Metric, + options: List[str] = []) -> Optional[rest.Response]: if not metric: return None - return MetricSeralizer(metric).serialize(options) + return MetricSerializer(metric).serialize(options) + + +def try_get_post_metric( + post: model.Post, + metric: model.Metric) -> Optional[model.PostMetric]: + return ( + db.session + .query(model.PostMetric) + .filter(model.PostMetric.metric == metric and + model.PostMetric.post == post) + .one_or_none()) def create_metric( tag: model.Tag, min: float, max: float) -> model.Metric: + assert tag if tag.metric is not None: raise MetricAlreadyExistsError('Tag already has a metric.') if min >= max: @@ -48,7 +73,8 @@ def create_metric( return metric -def update_or_create_metric(tag: model.Tag, metric_data) -> Optional[model.Metric]: +def update_or_create_metric(tag: model.Tag, metric_data: Any) -> Optional[model.Metric]: + assert tag for field in ('min', 'max'): if field not in metric_data: raise InvalidMetricError('Metric is missing %r field.' % field) @@ -61,6 +87,42 @@ def update_or_create_metric(tag: model.Tag, metric_data) -> Optional[model.Metri tag.metric.max = max return None else: - metric = model.Metric(tag=tag, min=min, max=max) - db.session.add(metric) - return metric + return create_metric(tag=tag, min=min, max=max) + + +def update_or_create_post_metric( + post: model.Post, + metric: model.Metric, + value: float) -> model.PostMetric: + assert post + assert metric + if metric.tag not in post.tags: + raise PostMissingTagError('Post doesn\'t have tag %r' % metric.tag.names[0]) + if value < metric.min or value > metric.max: + raise MetricValueOutOfRangeError('Metric value %r out of range.' % value) + db.session.query(model.PostMetric) + post_metric = try_get_post_metric(post, metric) + if post_metric is None: + post_metric = model.PostMetric(post=post, metric=metric, value=value) + db.session.add(post_metric) + else: + post_metric.value = value + return post_metric + + +def update_or_create_post_metrics(post: model.Post, metrics_data: Any) -> None: + """ + Overwrites any existing metric values, deletes other existing post metrics. + """ + assert post + post.metrics = [] + for metric_data in metrics_data: + for field in ('tag_name', 'value'): + if field not in metric_data: + raise InvalidMetricError('Metric is missing %r field.' % field) + value = float(metric_data['value']) + tag = tags.get_tag_by_name(metric_data['tag_name']) + if tag.metric is None: + raise MetricDoesNotExistsError('Tag %r has no metric.' % tag.names[0]) + post_metric = update_or_create_post_metric(post, tag.metric, value) + post.metrics.append(post_metric) diff --git a/server/szurubooru/model/metric.py b/server/szurubooru/model/metric.py index 2ec5b2b..14e8949 100644 --- a/server/szurubooru/model/metric.py +++ b/server/szurubooru/model/metric.py @@ -23,6 +23,7 @@ class PostMetric(Base): value = sa.Column('value', sa.Float, nullable=False, index=True) post = sa.orm.relationship('Post') + metric = sa.orm.relationship('Metric', back_populates='post_metrics') __mapper_args__ = { 'version_id_col': version, @@ -56,6 +57,7 @@ class PostMetricRange(Base): high = sa.Column('low', sa.Float, nullable=False) post = sa.orm.relationship('Post') + metric = sa.orm.relationship('Metric', back_populates='post_metric_ranges') __mapper_args__ = { 'version_id_col': version, @@ -82,9 +84,9 @@ class Metric(Base): tag = sa.orm.relationship('Tag') post_metrics = sa.orm.relationship( - 'PostMetric', backref='metric', cascade='all, delete-orphan') + 'PostMetric', back_populates='metric', cascade='all, delete-orphan') post_metric_ranges = sa.orm.relationship( - 'PostMetricRange', backref='metric', cascade='all, delete-orphan') + 'PostMetricRange', back_populates='metric', cascade='all, delete-orphan') post_metric_count = sa.orm.column_property( sa.sql.expression.select( diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 27a107a..21e0f0f 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -252,6 +252,28 @@ def post_favorite_factory(user_factory, post_factory): @pytest.fixture +def metric_factory(tag_factory): + def factory(tag=None): + if tag is None: + tag = tag_factory() + return model.Metric(tag=tag, min=0, max=10) + return factory + + +@pytest.fixture +def post_metric_factory(post_factory, metric_factory): + def factory(post=None, metric=None, value=None): + if post is None: + post = post_factory() + if metric is None: + metric = metric_factory() + if value is None: + value = (metric.min + metric.max)/2 + return model.PostMetric(post=post, metric=metric, value=value) + return factory + + +@pytest.fixture def read_asset(): def get(path): path = os.path.join(os.path.dirname(__file__), 'assets', path) diff --git a/server/szurubooru/tests/func/test_metrics.py b/server/szurubooru/tests/func/test_metrics.py index 9b0bf02..f522136 100644 --- a/server/szurubooru/tests/func/test_metrics.py +++ b/server/szurubooru/tests/func/test_metrics.py @@ -13,13 +13,24 @@ def test_serialize_metric(tag_factory): } +def test_try_get_post_metric( + post_factory, metric_factory, post_metric_factory): + post = post_factory() + metric = metric_factory() + metric2 = metric_factory() + post_metric = post_metric_factory(post=post, metric=metric) + db.session.add_all([post, metric, metric2, post_metric]) + db.session.flush() + assert metrics.try_get_post_metric(post, metric2) is None + assert metrics.try_get_post_metric(post, metric) is post_metric + + def test_create_metric(tag_factory): tag = tag_factory() db.session.add(tag) new_metric = metrics.create_metric(tag, 1, 2) assert new_metric is not None db.session.flush() - db.session.refresh(tag) assert tag.metric is not None assert tag.metric.min == 1 assert tag.metric.max == 2 @@ -44,7 +55,6 @@ def test_update_or_create_metric(tag_factory): new_metric = metrics.update_or_create_metric(tag, {'min': 1, 'max': 2}) assert new_metric is not None db.session.flush() - db.session.refresh(tag) assert tag.metric is not None assert tag.metric.min == 1 assert tag.metric.max == 2 @@ -52,7 +62,6 @@ def test_update_or_create_metric(tag_factory): new_metric = metrics.update_or_create_metric(tag, {'min': 3, 'max': 4}) assert new_metric is None db.session.flush() - db.session.refresh(tag) assert tag.metric.min == 3 assert tag.metric.max == 4 @@ -64,3 +73,95 @@ def test_update_or_create_metric_with_invalid_params(tag_factory, params): tag = tag_factory() with pytest.raises(metrics.InvalidMetricError): metrics.update_or_create_metric(tag, params) + + +def test_update_or_create_post_metric_without_tag(post_factory, metric_factory): + post = post_factory() + metric = metric_factory() + with pytest.raises(metrics.PostMissingTagError): + metrics.update_or_create_post_metric(post, metric, 1.5) + + +def test_update_or_create_post_metric_with_invalid_value( + post_factory, tag_factory, metric_factory): + post = post_factory() + tag = tag_factory() + post.tags = [tag] + metric = metric_factory(tag) + with pytest.raises(metrics.MetricValueOutOfRangeError): + metrics.update_or_create_post_metric(post, metric, -99) + + +def test_update_or_create_post_metric_create( + post_factory, tag_factory, metric_factory): + post = post_factory() + tag = tag_factory() + post.tags = [tag] + metric = metric_factory(tag) + db.session.add(metric) + db.session.flush() + post_metric = metrics.update_or_create_post_metric(post, metric, 1.5) + assert post_metric.value == 1.5 + + +def test_update_or_create_post_metric_update( + post_factory, tag_factory, metric_factory): + post = post_factory() + tag = tag_factory() + post.tags = [tag] + metric = metric_factory(tag) + post_metric = model.PostMetric(post=post, metric=metric, value=1.2) + db.session.add(post_metric) + db.session.flush() + + metrics.update_or_create_post_metric(post, metric, 3.4) + db.session.flush() + + assert post_metric.value == 3.4 + + +def test_update_or_create_post_metrics_missing_tag( + post_factory, tag_factory, metric_factory): + post = post_factory() + tag = tag_factory(names=['tag1']) + metric = metric_factory(tag) + db.session.add(metric) + db.session.flush() + data = [{'tag_name': 'tag1', 'value': 1.5}] + with pytest.raises(metrics.PostMissingTagError): + metrics.update_or_create_post_metrics(post, data) + + +@pytest.mark.parametrize('params', [ + [{}], + [{'tag_name': 'tag'}], + [{'value': 1.5}] +]) +def test_update_or_create_post_metrics_with_invalid_params( + params, post_factory): + post = post_factory() + with pytest.raises(metrics.InvalidMetricError): + metrics.update_or_create_post_metrics(post, params) + + +def test_update_or_create_post_metrics( + post_factory, tag_factory, metric_factory): + post = post_factory() + tag1 = tag_factory(names=['tag1']) + tag2 = tag_factory(names=['tag2']) + post.tags = [tag1, tag2] + metric1 = metric_factory(tag1) + metric2 = metric_factory(tag2) + db.session.add_all([metric1, metric2]) + db.session.flush() + + data = [ + {'tag_name': 'tag1', 'value': 1.2}, + {'tag_name': 'tag2', 'value': 3.4}, + ] + metrics.update_or_create_post_metrics(post, data) + db.session.flush() + + assert len(post.metrics) == 2 + assert post.metrics[0].value == 1.2 + assert post.metrics[1].value == 3.4 diff --git a/server/szurubooru/tests/model/test_metric.py b/server/szurubooru/tests/model/test_metric.py index d388031..7205f03 100644 --- a/server/szurubooru/tests/model/test_metric.py +++ b/server/szurubooru/tests/model/test_metric.py @@ -131,6 +131,43 @@ def test_cascade_delete_post(post_factory, tag_factory): assert db.session.query(model.PostMetric).count() == 1 +def test_delete_post_metric_no_cascade(metric_factory, post_metric_factory): + metric = metric_factory() + post_metric = post_metric_factory(metric=metric) + db.session.add_all([metric, post_metric]) + db.session.commit() + db.session.refresh(metric) + assert len(metric.post_metrics) == 1 + + db.session.delete(post_metric) + db.session.commit() + db.session.refresh(metric) + assert len(metric.post_metrics) == 0 + + +def test_cascade_delete_on_remove_metric_from_post( + post_factory, post_metric_factory): + post = post_factory() + post_metric = post_metric_factory(post=post) + db.session.add_all([post, post_metric]) + db.session.commit() + + assert not db.session.dirty + assert db.session.query(model.Post).count() == 1 + assert db.session.query(model.Tag).count() == 1 + assert db.session.query(model.Metric).count() == 1 + assert db.session.query(model.PostMetric).count() == 1 + + post.metrics.clear() + db.session.commit() + + assert not db.session.dirty + assert db.session.query(model.Post).count() == 1 + assert db.session.query(model.Tag).count() == 1 + assert db.session.query(model.Metric).count() == 1 + assert db.session.query(model.PostMetric).count() == 0 + + def test_tag_without_metric(tag_factory): tag = tag_factory(names=['mytag']) assert tag.metric is None @@ -145,5 +182,14 @@ def test_tag_without_metric(tag_factory): assert tag.metric is None -def test_metric_counts(): - pass +def test_metric_counts(post_factory, metric_factory): + post1 = post_factory() + post2 = post_factory() + metric = metric_factory() + post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2) + post_metric2 = model.PostMetric(post=post2, metric=metric, value=3.4) + post_metric_range = model.PostMetricRange(post=post1, metric=metric, low=5.6, high=7.8) + db.session.add_all([metric, post_metric1, post_metric2, post_metric_range]) + db.session.flush() + assert metric.post_metric_count == 2 + assert metric.post_metric_range_count == 1 |