From 5d32d7922f1977d0ee3d0d9c4655f563f517a24d Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Mon, 20 Dec 2021 21:07:12 +0000 Subject: [PATCH] Add Maintenance State field Fixes #160 --- app/blueprints/api/endpoints.py | 2 +- app/blueprints/packages/packages.py | 9 ++++ app/flatpages/help/api.md | 2 + app/flatpages/help/content_flags.md | 11 +++-- app/flatpages/help/package_config.md | 2 + app/flatpages/policy_and_guidance.md | 3 ++ app/logic/packages.py | 11 ++++- app/models/packages.py | 62 ++++++++++++++++++++++++- app/querybuilder.py | 16 ++++++- app/templates/packages/create_edit.html | 1 + app/templates/packages/view.html | 20 +++++++- migrations/versions/17b303f33f68_.py | 27 +++++++++++ 12 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 migrations/versions/17b303f33f68_.py diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index ae9a059a..54d739a6 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -385,7 +385,7 @@ def list_all_reviews(): query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package)) if request.args.get("author"): - query = query.join(User).filter(User.username == request.args.get("author")) + query = query.filter(PackageReview.author.has(User.username == request.args.get("author"))) if request.args.get("is_positive"): query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive"))) diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index 2dd0340b..c081cb11 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -228,12 +228,20 @@ def makeLabel(obj): else: return obj.title + +def NotNullOption(_form, field): + if field.data is None or field.data.name == "__None": + raise ValidationError("This field is required") + + class PackageForm(FlaskForm): type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD) title = StringField("Title (Human-readable)", [InputRequired(), Length(1, 100)]) name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)]) + dev_state = SelectField("Maintenance State", [InputRequired(), NotNullOption], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce) + tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel) content_warnings = QuerySelectMultipleField('Content Warnings', query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel) license = QuerySelectField("License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name) @@ -318,6 +326,7 @@ def create_edit(author=None, name=None): "title": form.title.data, "name": form.name.data, "short_desc": form.short_desc.data, + "dev_state": form.dev_state.data, "tags": form.tags.raw_data, "content_warnings": form.content_warnings.raw_data, "license": form.license.data, diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index a9b7da02..c279b265 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -61,6 +61,8 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * `title`: Human-readable title. * `name`: Technical name (needs permission if already approved). * `short_description` + * `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`, + `LOOKING_FOR_MAINTAINER`. * `tags`: List of [tag](#tags) names. * `content_warnings`: List of [content warning](#content-warnings) names. * `license`: A [license](#licenses) name. diff --git a/app/flatpages/help/content_flags.md b/app/flatpages/help/content_flags.md index c3b5071b..80356566 100644 --- a/app/flatpages/help/content_flags.md +++ b/app/flatpages/help/content_flags.md @@ -16,10 +16,15 @@ contentdb_flag_blacklist = nonfree, bad_language, drugs A flag can be: * `nonfree` - can be used to hide packages which do not qualify as - 'free software', as defined by the Free Software Foundation. + 'free software', as defined by the Free Software Foundation. +* `wip` - packages marked as Work in Progress +* `deprecated` - packages marked as Deprecated * A content warning, given below. -* `android_default` - meta-flag that filters out any content with a content warning. -* `desktop_default` - meta-flag that doesn't filter anything out for now. +* `android_default` - meta-flag that filters out any content with a content warning and WIP packages +* `desktop_default` - meta-flag that filters out WIP packages. + +The `_default` flags are designed so that we can change how different platforms filter the package list +based on ## Content Warnings diff --git a/app/flatpages/help/package_config.md b/app/flatpages/help/package_config.md index da380979..486a0759 100644 --- a/app/flatpages/help/package_config.md +++ b/app/flatpages/help/package_config.md @@ -50,6 +50,8 @@ It should be a JSON dictionary with one or more of the following optional keys: * `title`: Human-readable title. * `name`: Technical name (needs permission if already approved). * `short_description` +* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`, + `LOOKING_FOR_MAINTAINER`. * `tags`: List of tag names, see [/api/tags/](/api/tags/). * `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/). * `license`: A license name, see [/api/licenses/](/api/licenses/). diff --git a/app/flatpages/policy_and_guidance.md b/app/flatpages/policy_and_guidance.md index 70452942..35a0555a 100644 --- a/app/flatpages/policy_and_guidance.md +++ b/app/flatpages/policy_and_guidance.md @@ -46,6 +46,9 @@ but still has value. Note that this doesn't mean that you should add a thing you started working on yesterday, it's worth adding all the basic stuff to make your package useful. +You should make sure to mark Work in Progress stuff as such in the "maintenance status" column, +as this will help advise players. + Adding non-player facing mods, such as libraries and server tools, is perfectly fine and encouraged. ContentDB isn't just for player-facing things, and adding libraries allows them to be installed when a mod depends on it. diff --git a/app/logic/packages.py b/app/logic/packages.py index 5402ec50..f406bc9f 100644 --- a/app/logic/packages.py +++ b/app/logic/packages.py @@ -19,7 +19,8 @@ import re import validators from app.logic.LogicError import LogicError -from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, License, UserRank +from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \ + License, UserRank, PackageDevState from app.utils import addAuditLog @@ -47,6 +48,7 @@ ALLOWED_FIELDS = { "name": str, "short_description": str, "short_desc": str, + "dev_state": AnyType, "tags": list, "content_warnings": list, "license": AnyType, @@ -116,13 +118,18 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, if "type" in data: data["type"] = PackageType.coerce(data["type"]) + if "dev_state" in data: + data["dev_state"] = PackageDevState.coerce(data["dev_state"]) + if data["dev_state"] is None: + raise LogicError(400, "dev_state cannot be null") + if "license" in data: data["license"] = get_license(data["license"]) if "media_license" in data: data["media_license"] = get_license(data["media_license"]) - for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license", + for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license", "repo", "website", "issueTracker", "forums"]: if key in data: setattr(package, key, data[key]) diff --git a/app/models/packages.py b/app/models/packages.py index f1231c8b..ab341434 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -73,6 +73,65 @@ class PackageType(enum.Enum): return item if type(item) == PackageType else PackageType[item.upper()] +class PackageDevState(enum.Enum): + WIP = "Work in Progress" + BETA = "Beta" + ACTIVELY_DEVELOPED = "Actively Developed" + MAINTENANCE_ONLY = "Maintenance Only" + AS_IS = "As-Is" + DEPRECATED = "Deprecated" + LOOKING_FOR_MAINTAINER = "Looking for Maintainer" + + def toName(self): + return self.name.lower() + + def __str__(self): + return self.name + + def get_desc(self): + if self == PackageDevState.WIP: + return "Under active development, and may break worlds/things without warning" + elif self == PackageDevState.BETA: + return "Fully playable, but with some breakages/changes expected" + elif self == PackageDevState.MAINTENANCE_ONLY: + return "Finished, with bug fixes being made as needed" + elif self == PackageDevState.AS_IS: + return "Finished, the maintainer doesn't intend to continue working on it or provide support" + elif self == PackageDevState.DEPRECATED: + return "The maintainer doesn't recommend this package. See the description for more info" + else: + return None + + @classmethod + def get(cls, name): + try: + return PackageDevState[name.upper()] + except KeyError: + return None + + @classmethod + def choices(cls, with_none): + def build_label(choice): + desc = choice.get_desc() + if desc is None: + return choice.value + else: + return f"{choice.value}: {desc}" + + ret = [(choice, build_label(choice)) for choice in cls] + + if with_none: + ret.insert(0, ("__None", "")) + + return ret + + @classmethod + def coerce(cls, item): + if item is None or (isinstance(item, str) and item.upper() == "__NONE"): + return None + return item if type(item) == PackageDevState else PackageDevState[item.upper()] + + class PackageState(enum.Enum): WIP = "Draft" CHANGES_NEEDED = "Changes Needed" @@ -292,7 +351,8 @@ class Package(db.Model): media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1) media_license = db.relationship("License", foreign_keys=[media_license_id]) - state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP) + state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP) + dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None) @property def approved(self): diff --git a/app/querybuilder.py b/app/querybuilder.py index d7f7d3db..6daa19cc 100644 --- a/app/querybuilder.py +++ b/app/querybuilder.py @@ -3,7 +3,8 @@ from sqlalchemy import or_ from sqlalchemy.orm import subqueryload from sqlalchemy.sql.expression import func -from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, ContentWarning, PackageState +from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, \ + ContentWarning, PackageState, PackageDevState from .utils import isYes, get_int_or_abort @@ -30,7 +31,6 @@ class QueryBuilder: # Hide hide_flags = args.getlist("hide") - self.title = title self.types = types self.tags = tags @@ -41,9 +41,16 @@ class QueryBuilder: self.order_by = args.get("sort") self.order_dir = args.get("order") or "desc" + use_platform_defaults = "android_default" in hide_flags or "desktop_default" in hide_flags + self.hide_nonfree = "nonfree" in hide_flags + self.hide_wip = "wip" in hide_flags or use_platform_defaults + self.hide_deprecated = "deprecated" in hide_flags or use_platform_defaults + self.hide_flags = set(hide_flags) self.hide_flags.discard("nonfree") + self.hide_flags.discard("wip") + self.hide_flags.discard("deprecated") # Filters self.search = args.get("q") @@ -136,6 +143,11 @@ class QueryBuilder: query = query.filter(Package.license.has(License.is_foss == True)) query = query.filter(Package.media_license.has(License.is_foss == True)) + if self.hide_wip: + query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.WIP)) + if self.hide_deprecated: + query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.DEPRECATED)) + if self.version: query = query.join(Package.releases) \ .filter(PackageRelease.approved == True) \ diff --git a/app/templates/packages/create_edit.html b/app/templates/packages/create_edit.html index cfe8c286..5bf0c319 100644 --- a/app/templates/packages/create_edit.html +++ b/app/templates/packages/create_edit.html @@ -77,6 +77,7 @@ {% endif %} {{ render_field(form.short_desc, class_="pkg_meta") }} + {{ render_field(form.dev_state, class_="pkg_meta", hint=_("Please choose 'Work in Progress' if your package is unstable, and shouldn't be recommended to all players")) }} {{ render_multiselect_field(form.tags, class_="pkg_meta") }} {{ render_multiselect_field(form.content_warnings, class_="pkg_meta") }}
diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html index 1de5fac0..9102c4e2 100644 --- a/app/templates/packages/view.html +++ b/app/templates/packages/view.html @@ -71,6 +71,12 @@

+ {% if package.dev_state.name == "LOOKING_FOR_MAINTAINER" or package.dev_state.name == "DEPRECATED" %} + + + {{ package.dev_state.value }} + + {% endif %} {% if package_warning %} @@ -84,6 +90,12 @@ {{ warning.title }} {% endfor %} + {% if package.dev_state.name == "WIP" %} + + + {{ _("Work in Progress") }} + + {% endif %} {% for t in package.tags %} -

Added
+
{{ _("Maintenance State") }}
+ {% if package.dev_state %} +
{{ package.dev_state.value }}
+ {% else %} +
Unknown
+ {% endif %} +
{{ _("Added") }}
{{ package.created_at | datetime }}
Maintainers
diff --git a/migrations/versions/17b303f33f68_.py b/migrations/versions/17b303f33f68_.py new file mode 100644 index 00000000..ebe68cae --- /dev/null +++ b/migrations/versions/17b303f33f68_.py @@ -0,0 +1,27 @@ +"""empty message + +Revision ID: 17b303f33f68 +Revises: 96a01fe23389 +Create Date: 2021-12-20 19:48:58.571336 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '17b303f33f68' +down_revision = '96a01fe23389' +branch_labels = None +depends_on = None + + +def upgrade(): + status = postgresql.ENUM('WIP', 'BETA', 'ACTIVELY_DEVELOPED', 'MAINTENANCE_ONLY', 'AS_IS', 'DEPRECATED', 'LOOKING_FOR_MAINTAINER', name='packagedevstate') + status.create(op.get_bind()) + + op.add_column('package', sa.Column('dev_state', sa.Enum('WIP', 'BETA', 'ACTIVELY_DEVELOPED', 'MAINTENANCE_ONLY', 'AS_IS', 'DEPRECATED', 'LOOKING_FOR_MAINTAINER', name='packagedevstate'), nullable=True)) + + +def downgrade(): + op.drop_column('package', 'dev_state')