diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index baa7a2d2..c3f475fe 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -578,6 +578,16 @@ def all_deps(): }) +@bp.route("/api/users//") +@cors_allowed +def user_view(username: str): + user = User.query.filter_by(username=username).first() + if user is None: + error(404, "User not found") + + return jsonify(user.get_dict()) + + @bp.route("/api/users//stats/") @cors_allowed def user_stats(username: str): diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index d2b00177..491b9846 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -166,7 +166,7 @@ Supported query parameters: * `short`: stuff needed for the Minetest client. -## Releases +### Releases * GET `/api/releases/` (List) * Limited to 30 most recent releases. @@ -228,7 +228,7 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/ ``` -## Screenshots +### Screenshots * GET `/api/packages///screenshots/` (List) * Returns array of screenshot dictionaries with keys: @@ -289,7 +289,7 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots ``` -## Reviews +### Reviews * GET `/api/packages///reviews/` (List) * Returns array of review dictionaries with keys: @@ -333,6 +333,36 @@ Example: ``` +## Users + +* GET `/api/users//` + * `username` + * `display_name`: human-readable name to be displayed in GUIs. + * `rank`: ContentDB [rank](/help/ranks_permissions/). + * `profile_pic_url`: URL to profile picture, or null. + * `website_url`: URL to website, or null. + * `donate_url`: URL to donate page, or null. + * `connections`: object + * `github`: GitHub username, or null. + * `forums`: forums username, or null. + * `links`: object + * `api_packages`: URL to API to list this user's packages. + * `profile`: URL to the HTML profile page. +* GET `/api/users//stats/` + * Returns daily stats for the user's packages, or null if there is no data. + * Daily date is done based on the UTC timezone. + * EXPERIMENTAL. This API may change without warning. + * A table with the following keys: + * `from`: start date, inclusive. Ex: 2022-10-22. + * `end`: end date, inclusive. Ex: 2022-11-05. + * `package_downloads`: map of package title to list of integers per day. + * `platform_minetest`: list of integers per day. + * `platform_other`: list of integers per day. + * `reason_new`: list of integers per day. + * `reason_dependency`: list of integers per day. + * `reason_update`: list of integers per day. + + ## Topics * GET `/api/topics/` ([View](/api/topics/)) diff --git a/app/models/users.py b/app/models/users.py index 3a815206..8c26eab5 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -18,6 +18,7 @@ import datetime import enum +from flask import url_for from flask_login import UserMixin from sqlalchemy import desc, text @@ -161,17 +162,17 @@ class User(db.Model, UserMixin): # Content notifications = db.relationship("Notification", foreign_keys="Notification.user_id", - order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan") + order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan") caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id", - back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic") + back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic") notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user", - cascade="all, delete, delete-orphan") + cascade="all, delete, delete-orphan") email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id", - back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic") + back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic") audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer", - order_by=desc("audit_log_entry_created_at"), lazy="dynamic") + order_by=desc("audit_log_entry_created_at"), lazy="dynamic") maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title")) @@ -185,6 +186,24 @@ class User(db.Model, UserMixin): ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False) + def get_dict(self): + return { + "username": self.username, + "display_name": self.display_name, + "rank": self.rank.name.lower(), + "profile_pic_url": self.profile_pic, + "website_url": self.website_url, + "donate_url": self.donate_url, + "connections": { + "github": self.github_username, + "forums": self.forums_username, + }, + "links": { + "api_packages": url_for("api.packages", author=self.username), + "profile": url_for("users.profile", username=self.username), + } + } + def __init__(self, username=None, active=False, email=None, password=None): self.username = username self.display_name = username @@ -195,7 +214,7 @@ class User(db.Model, UserMixin): def canAccessTodoList(self): return Permission.APPROVE_NEW.check(self) or \ - Permission.APPROVE_RELEASE.check(self) + Permission.APPROVE_RELEASE.check(self) def isClaimed(self): return self.rank.atLeast(UserRank.NEW_MEMBER) @@ -272,7 +291,7 @@ class User(db.Model, UserMixin): hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) return Thread.query.filter_by(author=self) \ - .filter(Thread.created_at > hour_ago).count() < 2 * factor + .filter(Thread.created_at > hour_ago).count() < 2 * factor def canReviewRL(self): from app.models import PackageReview @@ -290,7 +309,7 @@ class User(db.Model, UserMixin): hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) return PackageReview.query.filter_by(author=self) \ - .filter(PackageReview.created_at > hour_ago).count() < 10 * factor + .filter(PackageReview.created_at > hour_ago).count() < 10 * factor def __eq__(self, other): @@ -305,8 +324,8 @@ class User(db.Model, UserMixin): def can_see_edit_profile(self, current_user): return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \ - self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \ - self.checkPerm(current_user, Permission.CHANGE_RANK) + self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \ + self.checkPerm(current_user, Permission.CHANGE_RANK) def can_delete(self): from app.models import ForumTopic