diff --git a/salt/salt/postgresql/site-db.sls b/salt/salt/postgresql/site-db.sls index 5f73a1e..91adefb 100644 --- a/salt/salt/postgresql/site-db.sls +++ b/salt/salt/postgresql/site-db.sls @@ -39,6 +39,13 @@ site-db-enable-pg_stat_statements: - require: - postgres_database: tildes +site-db-enable-pg_trgm: + postgres_extension.present: + - name: pg_trgm + - maintenance_db: tildes + - require: + - postgres_database: tildes + site-db-pg_hba-lines: file.accumulated: - name: pg_hba_lines diff --git a/salt/salt/postgresql/test-db.sls b/salt/salt/postgresql/test-db.sls index 813f73d..ad18163 100644 --- a/salt/salt/postgresql/test-db.sls +++ b/salt/salt/postgresql/test-db.sls @@ -26,6 +26,13 @@ test-db-enable-intarray: - require: - postgres_database: tildes_test +test-db-enable-pg_trgm: + postgres_extension.present: + - name: pg_trgm + - maintenance_db: tildes_test + - require: + - postgres_database: tildes_test + test-db-pg_hba-lines: file.accumulated: - name: pg_hba_lines diff --git a/salt/salt/redis/modules/rebloom.sls b/salt/salt/redis/modules/rebloom.sls index 1e1787c..ad84ca0 100644 --- a/salt/salt/redis/modules/rebloom.sls +++ b/salt/salt/redis/modules/rebloom.sls @@ -1,3 +1,7 @@ +# Take care if updating this module - Redis Labs changed the license on July 16, 2018 +# to Apache 2 with their "Commons Clause": https://commonsclause.com/ +# The legality and specific implications of that clause are currently unclear, so we +# probably shouldn't update to a version under that license without more research. rebloom-clone: git.latest: - name: https://github.com/RedisLabsModules/rebloom diff --git a/tildes/alembic/versions/3fbddcba0e3b_add_comment_remove_and_comment_unremove_.py b/tildes/alembic/versions/3fbddcba0e3b_add_comment_remove_and_comment_unremove_.py new file mode 100644 index 0000000..d9a796f --- /dev/null +++ b/tildes/alembic/versions/3fbddcba0e3b_add_comment_remove_and_comment_unremove_.py @@ -0,0 +1,36 @@ +"""Add COMMENT_REMOVE and COMMENT_UNREMOVE to logeventtype + +Revision ID: 3fbddcba0e3b +Revises: 6a635773de8f +Create Date: 2018-08-26 04:34:51.741972 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3fbddcba0e3b" +down_revision = "6a635773de8f" +branch_labels = None +depends_on = None + + +def upgrade(): + # ALTER TYPE doesn't work from inside a transaction, disable it + connection = None + if not op.get_context().as_sql: + connection = op.get_bind() + connection.execution_options(isolation_level="AUTOCOMMIT") + + op.execute("ALTER TYPE logeventtype ADD VALUE IF NOT EXISTS 'COMMENT_REMOVE'") + op.execute("ALTER TYPE logeventtype ADD VALUE IF NOT EXISTS 'COMMENT_UNREMOVE'") + + # re-activate the transaction for any future migrations + if connection is not None: + connection.execution_options(isolation_level="READ_COMMITTED") + + +def downgrade(): + # no way to remove enum values, just do nothing + pass diff --git a/tildes/alembic/versions/6a635773de8f_add_comment_post_to_logeventtype.py b/tildes/alembic/versions/6a635773de8f_add_comment_post_to_logeventtype.py new file mode 100644 index 0000000..5218b69 --- /dev/null +++ b/tildes/alembic/versions/6a635773de8f_add_comment_post_to_logeventtype.py @@ -0,0 +1,35 @@ +"""Add COMMENT_POST to logeventtype + +Revision ID: 6a635773de8f +Revises: b3be50625592 +Create Date: 2018-08-26 01:56:13.511360 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "6a635773de8f" +down_revision = "b3be50625592" +branch_labels = None +depends_on = None + + +def upgrade(): + # ALTER TYPE doesn't work from inside a transaction, disable it + connection = None + if not op.get_context().as_sql: + connection = op.get_bind() + connection.execution_options(isolation_level="AUTOCOMMIT") + + op.execute("ALTER TYPE logeventtype ADD VALUE IF NOT EXISTS 'COMMENT_POST'") + + # re-activate the transaction for any future migrations + if connection is not None: + connection.execution_options(isolation_level="READ_COMMITTED") + + +def downgrade(): + # can't remove from enums, do nothing + pass diff --git a/tildes/alembic/versions/a1708d376252_drop_topics_removed_time_column.py b/tildes/alembic/versions/a1708d376252_drop_topics_removed_time_column.py new file mode 100644 index 0000000..ef78348 --- /dev/null +++ b/tildes/alembic/versions/a1708d376252_drop_topics_removed_time_column.py @@ -0,0 +1,32 @@ +"""Drop topics.removed_time column + +Revision ID: a1708d376252 +Revises: bcf1406bb6c5 +Create Date: 2018-08-23 00:29:41.024890 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "a1708d376252" +down_revision = "bcf1406bb6c5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column("topics", "removed_time") + + +def downgrade(): + op.add_column( + "topics", + sa.Column( + "removed_time", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + ) diff --git a/tildes/alembic/versions/b3be50625592_add_log_comments_table.py b/tildes/alembic/versions/b3be50625592_add_log_comments_table.py new file mode 100644 index 0000000..966aba0 --- /dev/null +++ b/tildes/alembic/versions/b3be50625592_add_log_comments_table.py @@ -0,0 +1,58 @@ +"""Add log_comments table + +Revision ID: b3be50625592 +Revises: a1708d376252 +Create Date: 2018-08-23 04:20:55.819209 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b3be50625592" +down_revision = "a1708d376252" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE TABLE log_comments (comment_id integer not null) INHERITS (log)") + op.create_foreign_key( + op.f("fk_log_comments_comment_id_comments"), + "log_comments", + "comments", + ["comment_id"], + ["comment_id"], + ) + op.create_index( + op.f("ix_log_comments_comment_id"), "log_comments", ["comment_id"], unique=False + ) + + # duplicate all the indexes/constraints from the base log table + op.create_primary_key(op.f("pk_log_comments"), "log_comments", ["log_id"]) + op.create_index( + op.f("ix_log_comments_event_time"), "log_comments", ["event_time"], unique=False + ) + op.create_index( + op.f("ix_log_comments_event_type"), "log_comments", ["event_type"], unique=False + ) + op.create_index( + op.f("ix_log_comments_ip_address"), "log_comments", ["ip_address"], unique=False + ) + op.create_index( + op.f("ix_log_comments_user_id"), "log_comments", ["user_id"], unique=False + ) + + op.create_foreign_key( + op.f("fk_log_comments_user_id_users"), + "log_comments", + "users", + ["user_id"], + ["user_id"], + ) + + +def downgrade(): + op.drop_index(op.f("ix_log_comments_comment_id"), table_name="log_comments") + op.drop_table("log_comments") diff --git a/tildes/alembic/versions/bcf1406bb6c5_add_admin_tool_for_removing_topics.py b/tildes/alembic/versions/bcf1406bb6c5_add_admin_tool_for_removing_topics.py new file mode 100644 index 0000000..dae0173 --- /dev/null +++ b/tildes/alembic/versions/bcf1406bb6c5_add_admin_tool_for_removing_topics.py @@ -0,0 +1,36 @@ +"""Add admin tool for removing topics + +Revision ID: bcf1406bb6c5 +Revises: 50c251c4a19c +Create Date: 2018-08-22 23:56:41.733065 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bcf1406bb6c5" +down_revision = "50c251c4a19c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ALTER TYPE doesn't work from inside a transaction, disable it + connection = None + if not op.get_context().as_sql: + connection = op.get_bind() + connection.execution_options(isolation_level="AUTOCOMMIT") + + op.execute("ALTER TYPE logeventtype ADD VALUE IF NOT EXISTS 'TOPIC_REMOVE'") + op.execute("ALTER TYPE logeventtype ADD VALUE IF NOT EXISTS 'TOPIC_UNREMOVE'") + + # re-activate the transaction for any future migrations + if connection is not None: + connection.execution_options(isolation_level="READ_COMMITTED") + + +def downgrade(): + # no way to remove enum values, just do nothing + pass diff --git a/tildes/mypy.ini b/tildes/mypy.ini index 18aa8b0..4ac6f8b 100644 --- a/tildes/mypy.ini +++ b/tildes/mypy.ini @@ -2,3 +2,4 @@ mypy_path = /opt/tildes/stubs/ disallow_untyped_defs = true ignore_missing_imports = true +no_implicit_optional = true diff --git a/tildes/requirements-to-freeze.txt b/tildes/requirements-to-freeze.txt index 39cc8a1..5e15100 100644 --- a/tildes/requirements-to-freeze.txt +++ b/tildes/requirements-to-freeze.txt @@ -37,6 +37,7 @@ SQLAlchemy SQLAlchemy-Utils stripe testing.redis +titlecase webargs webtest zope.sqlalchemy diff --git a/tildes/requirements.txt b/tildes/requirements.txt index aacc564..74dc993 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -83,6 +83,7 @@ SQLAlchemy-Utils==0.33.3 stripe==2.4.0 testing.common.database==2.0.3 testing.redis==1.1.1 +titlecase==0.12.0 toml==0.9.4 traitlets==4.3.2 transaction==2.2.1 diff --git a/tildes/scripts/initialize_db.py b/tildes/scripts/initialize_db.py index 6f4f472..3df4b27 100644 --- a/tildes/scripts/initialize_db.py +++ b/tildes/scripts/initialize_db.py @@ -38,7 +38,7 @@ def initialize_db(config_path: str, alembic_config_path: Optional[str] = None) - def create_tables(connectable: Connectable) -> None: """Create the database tables.""" # tables to skip (due to inheritance or other need to create manually) - excluded_tables = Log.INHERITED_TABLES + excluded_tables = Log.INHERITED_TABLES + ["log"] tables = [ table @@ -47,6 +47,9 @@ def create_tables(connectable: Connectable) -> None: ] DatabaseModel.metadata.create_all(connectable, tables=tables) + # create log table (and inherited ones) last + DatabaseModel.metadata.create_all(connectable, tables=[Log.__table__]) + def run_sql_scripts_in_dir(path: str, engine: Engine) -> None: """Run all sql scripts in a directory.""" diff --git a/tildes/scss/modules/_comment.scss b/tildes/scss/modules/_comment.scss index 64aa7f6..ca40275 100644 --- a/tildes/scss/modules/_comment.scss +++ b/tildes/scss/modules/_comment.scss @@ -88,6 +88,12 @@ overflow: auto; } +.comment-removed-warning { + color: $warning-color; + font-weight: bold; + font-size: 0.6rem; +} + .comment-votes { font-size: 0.6rem; font-weight: bold; diff --git a/tildes/scss/modules/_empty.scss b/tildes/scss/modules/_empty.scss index ddd8275..6d344f4 100644 --- a/tildes/scss/modules/_empty.scss +++ b/tildes/scss/modules/_empty.scss @@ -2,3 +2,11 @@ background: inherit; color: inherit; } + +.empty-list { + list-style-type: none; + + li { + max-width: unset; + } +} diff --git a/tildes/scss/modules/_form.scss b/tildes/scss/modules/_form.scss index aa70e2a..876bda3 100644 --- a/tildes/scss/modules/_form.scss +++ b/tildes/scss/modules/_form.scss @@ -91,3 +91,7 @@ textarea.form-input { flex: 4; } } + +.form-search .form-input { + margin-right: 0.4rem; +} diff --git a/tildes/tests/test_topic.py b/tildes/tests/test_topic.py index 723d91e..e9c21fd 100644 --- a/tildes/tests/test_topic.py +++ b/tildes/tests/test_topic.py @@ -130,3 +130,19 @@ def test_multiple_edits_update_time(text_topic): def test_topic_initial_last_activity_time(text_topic): """Ensure last_activity_time is initially the same as created_time.""" assert text_topic.last_activity_time == text_topic.created_time + + +def test_convert_all_caps(session_user, session_group): + """Ensure that all-caps titles are converted to title case.""" + topic = Topic.create_link_topic( + session_group, session_user, "THE TITLE", "http://example.com" + ) + assert topic.title == "The Title" + + +def test_no_convert_partial_caps_title(session_user, session_group): + """Ensure that partially-caps titles are not converted to title case.""" + topic = Topic.create_link_topic( + session_group, session_user, "This is a TITLE", "http://example.com" + ) + assert topic.title == "This is a TITLE" diff --git a/tildes/tildes/enums.py b/tildes/tildes/enums.py index 18c50ec..bd6b7f9 100644 --- a/tildes/tildes/enums.py +++ b/tildes/tildes/enums.py @@ -48,12 +48,18 @@ class LogEventType(enum.Enum): USER_LOG_OUT = enum.auto() USER_REGISTER = enum.auto() + COMMENT_POST = enum.auto() + COMMENT_REMOVE = enum.auto() + COMMENT_UNREMOVE = enum.auto() + TOPIC_LOCK = enum.auto() TOPIC_MOVE = enum.auto() TOPIC_POST = enum.auto() + TOPIC_REMOVE = enum.auto() TOPIC_TAG = enum.auto() TOPIC_TITLE_EDIT = enum.auto() TOPIC_UNLOCK = enum.auto() + TOPIC_UNREMOVE = enum.auto() class TopicSortOption(enum.Enum): diff --git a/tildes/tildes/models/comment/comment.py b/tildes/tildes/models/comment/comment.py index 5a802fa..3860ba6 100644 --- a/tildes/tildes/models/comment/comment.py +++ b/tildes/tildes/models/comment/comment.py @@ -185,6 +185,9 @@ class Comment(DatabaseModel): # - logged-in users can mark comments read acl.append((Allow, Authenticated, "mark_read")) + # tools that require specifically granted permissions + acl.append((Allow, "admin", "remove")) + acl.append(DENY_ALL) return acl diff --git a/tildes/tildes/models/group/group.py b/tildes/tildes/models/group/group.py index aed3f22..d970545 100644 --- a/tildes/tildes/models/group/group.py +++ b/tildes/tildes/models/group/group.py @@ -62,7 +62,7 @@ class Group(DatabaseModel): """Order groups by their string representation.""" return str(self) < str(other) - def __init__(self, path: str, short_desc: str = None) -> None: + def __init__(self, path: str, short_desc: Optional[str] = None) -> None: """Create a new group.""" self.path = Ltree(path) self.short_description = short_desc diff --git a/tildes/tildes/models/log/__init__.py b/tildes/tildes/models/log/__init__.py index a6e3c6b..19a363e 100644 --- a/tildes/tildes/models/log/__init__.py +++ b/tildes/tildes/models/log/__init__.py @@ -1,3 +1,3 @@ """Contains models related to logs.""" -from .log import Log, LogTopic +from .log import Log, LogComment, LogTopic diff --git a/tildes/tildes/models/log/log.py b/tildes/tildes/models/log/log.py index c44c98d..e7faec5 100644 --- a/tildes/tildes/models/log/log.py +++ b/tildes/tildes/models/log/log.py @@ -13,6 +13,7 @@ from sqlalchemy.sql.expression import text from tildes.enums import LogEventType from tildes.models import DatabaseModel +from tildes.models.comment import Comment from tildes.models.topic import Topic @@ -65,7 +66,7 @@ class Log(DatabaseModel, BaseLog): __tablename__ = "log" - INHERITED_TABLES = ["log_topics"] + INHERITED_TABLES = ["log_comments", "log_topics"] def __init__( self, @@ -86,6 +87,35 @@ class Log(DatabaseModel, BaseLog): self.info = info +class LogComment(DatabaseModel, BaseLog): + """Model for a log entry related to a specific comment.""" + + __tablename__ = "log_comments" + + comment_id: int = Column( + Integer, ForeignKey("comments.comment_id"), index=True, nullable=False + ) + + comment: Comment = relationship("Comment") + + def __init__( + self, + event_type: LogEventType, + request: Request, + comment: Comment, + info: Optional[Dict[str, Any]] = None, + ) -> None: + """Create a new log entry related to a specific comment.""" + # pylint: disable=non-parent-init-called + Log.__init__(self, event_type, request, info) + + self.comment = comment + + def __str__(self) -> str: + """Return a string representation of the log event.""" + return f"performed action {self.event_type.name}" # noqa + + class LogTopic(DatabaseModel, BaseLog): """Model for a log entry related to a specific topic.""" @@ -112,6 +142,7 @@ class LogTopic(DatabaseModel, BaseLog): def __str__(self) -> str: """Return a string representation of the log event.""" + # pylint: disable=too-many-return-statements if self.event_type == LogEventType.TOPIC_TAG: return self._tag_event_description() elif self.event_type == LogEventType.TOPIC_MOVE: @@ -120,8 +151,12 @@ class LogTopic(DatabaseModel, BaseLog): return f"moved from ~{old_group} to ~{new_group}" elif self.event_type == LogEventType.TOPIC_LOCK: return "locked comments" + elif self.event_type == LogEventType.TOPIC_REMOVE: + return "removed" elif self.event_type == LogEventType.TOPIC_UNLOCK: return "unlocked comments" + elif self.event_type == LogEventType.TOPIC_UNREMOVE: + return "un-removed" elif self.event_type == LogEventType.TOPIC_TITLE_EDIT: old_title = self.info["old"] # noqa new_title = self.info["new"] # noqa @@ -187,6 +222,27 @@ def create_inherited_tables( ix_name = naming["ix"] % {"table_name": "log_topics", "column_0_name": "topic_id"} connection.execute(f"CREATE INDEX {ix_name} ON log_topics (topic_id)") + # log_comments + connection.execute( + "CREATE TABLE log_comments (comment_id integer not null) INHERITS (log)" + ) + + fk_name = naming["fk"] % { + "table_name": "log_comments", + "column_0_name": "comment_id", + "referred_table_name": "comments", + } + connection.execute( + f"ALTER TABLE log_comments ADD CONSTRAINT {fk_name} " + "FOREIGN KEY (comment_id) REFERENCES comments (comment_id)" + ) + + ix_name = naming["ix"] % { + "table_name": "log_comments", + "column_0_name": "comment_id", + } + connection.execute(f"CREATE INDEX {ix_name} ON log_comments (comment_id)") + # duplicate all the indexes/constraints from the base log table for table in Log.INHERITED_TABLES: pk_name = naming["pk"] % {"table_name": table} diff --git a/tildes/tildes/models/topic/topic.py b/tildes/tildes/models/topic/topic.py index b822862..540360c 100644 --- a/tildes/tildes/models/topic/topic.py +++ b/tildes/tildes/models/topic/topic.py @@ -20,6 +20,7 @@ from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.orm import deferred, relationship from sqlalchemy.sql.expression import text from sqlalchemy_utils import Ltree +from titlecase import titlecase from tildes.enums import TopicType from tildes.lib.database import ArrayOfLtree @@ -91,7 +92,6 @@ class Topic(DatabaseModel): is_removed: bool = Column( Boolean, nullable=False, server_default="false", index=True ) - removed_time: Optional[datetime] = Column(TIMESTAMP(timezone=True)) title: str = Column( Text, CheckConstraint(f"LENGTH(title) <= {TITLE_MAX_LENGTH}", name="title_length"), @@ -172,6 +172,11 @@ class Topic(DatabaseModel): new_topic = cls() new_topic.group_id = group.group_id new_topic.user_id = author.user_id + + # if the title is all caps, convert to title case + if title.isupper(): + title = titlecase(title) + new_topic.title = title return new_topic @@ -276,6 +281,8 @@ class Topic(DatabaseModel): # tools that require specifically granted permissions acl.append((Allow, "admin", "lock")) + acl.append((Allow, "admin", "remove")) + acl.append((Allow, "admin", "move")) acl.append((Allow, "topic.move", "move")) diff --git a/tildes/tildes/models/user/user.py b/tildes/tildes/models/user/user.py index 7763123..683a245 100644 --- a/tildes/tildes/models/user/user.py +++ b/tildes/tildes/models/user/user.py @@ -219,3 +219,8 @@ class User(DatabaseModel): return self.permissions raise ValueError("Unknown permissions format") + + @property + def is_admin(self) -> bool: + """Return whether the user has admin permissions.""" + return "admin" in self.auth_principals diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index f39b70f..a29e09c 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -115,11 +115,15 @@ def add_intercooler_routes(config: Configurator) -> None: ) add_ic_route("topic_group", "/topics/{topic_id36}/group", factory=topic_by_id36) add_ic_route("topic_lock", "/topics/{topic_id36}/lock", factory=topic_by_id36) + add_ic_route("topic_remove", "/topics/{topic_id36}/remove", factory=topic_by_id36) add_ic_route("topic_title", "/topics/{topic_id36}/title", factory=topic_by_id36) add_ic_route("topic_vote", "/topics/{topic_id36}/vote", factory=topic_by_id36) add_ic_route("topic_tags", "/topics/{topic_id36}/tags", factory=topic_by_id36) add_ic_route("comment", "/comments/{comment_id36}", factory=comment_by_id36) + add_ic_route( + "comment_remove", "/comments/{comment_id36}/remove", factory=comment_by_id36 + ) add_ic_route( "comment_replies", "/comments/{comment_id36}/replies", factory=comment_by_id36 ) diff --git a/tildes/tildes/schemas/fields.py b/tildes/tildes/schemas/fields.py index ef59e54..2f43ebd 100644 --- a/tildes/tildes/schemas/fields.py +++ b/tildes/tildes/schemas/fields.py @@ -16,7 +16,9 @@ from tildes.lib.string import simplify_string class Enum(Field): """Field for a native Python Enum (or subclasses).""" - def __init__(self, enum_class: Type = None, *args: Any, **kwargs: Any) -> None: + def __init__( + self, enum_class: Optional[Type] = None, *args: Any, **kwargs: Any + ) -> None: """Initialize the field with an optional enum class.""" super().__init__(*args, **kwargs) self._enum_class = enum_class @@ -77,7 +79,7 @@ class Markdown(Field): DEFAULT_MAX_LENGTH = 50_000 - def __init__(self, max_length: int = None, **kwargs: Any) -> None: + def __init__(self, max_length: Optional[int] = None, **kwargs: Any) -> None: """Initialize the field with a length validator.""" if not max_length: max_length = self.DEFAULT_MAX_LENGTH @@ -115,7 +117,7 @@ class SimpleString(Field): DEFAULT_MAX_LENGTH = 200 - def __init__(self, max_length: int = None, **kwargs: Any) -> None: + def __init__(self, max_length: Optional[int] = None, **kwargs: Any) -> None: """Initialize the field with a length validator.""" if not max_length: max_length = self.DEFAULT_MAX_LENGTH diff --git a/tildes/tildes/templates/error_group_not_found.jinja2 b/tildes/tildes/templates/error_group_not_found.jinja2 new file mode 100644 index 0000000..0380ec7 --- /dev/null +++ b/tildes/tildes/templates/error_group_not_found.jinja2 @@ -0,0 +1,26 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/links.jinja2' import group_linked %} + +{% block title %} +Group not found +{% endblock %} + +{% block content %} + +
Did you mean one of these groups instead?
+Comment removed
+ {% if comment.is_removed %} +This comment has been removed and is not visible to other users
{% endif %} {{ comment.rendered_html|safe }} @@ -160,6 +160,30 @@ >Delete {% endif %} + {% if request.has_permission("remove", comment) %} +
diff --git a/tildes/tildes/views/api/web/comment.py b/tildes/tildes/views/api/web/comment.py index e17fc41..3fb5f41 100644 --- a/tildes/tildes/views/api/web/comment.py +++ b/tildes/tildes/views/api/web/comment.py @@ -10,9 +10,10 @@ from sqlalchemy.orm.exc import FlushError from webargs.pyramidparser import use_kwargs from zope.sqlalchemy import mark_changed -from tildes.enums import CommentNotificationType, CommentTagOption +from tildes.enums import CommentNotificationType, CommentTagOption, LogEventType from tildes.lib.datetime import utc_now from tildes.models.comment import Comment, CommentNotification, CommentTag, CommentVote +from tildes.models.log import LogComment from tildes.models.topic import TopicVisit from tildes.schemas.comment import CommentSchema, CommentTagSchema from tildes.views import IC_NOOP @@ -65,6 +66,8 @@ def post_toplevel_comment(request: Request, markdown: str) -> dict: new_comment = Comment(topic=topic, author=request.user, markdown=markdown) request.db_session.add(new_comment) + request.db_session.add(LogComment(LogEventType.COMMENT_POST, request, new_comment)) + if topic.user != request.user and not topic.is_deleted: notification = CommentNotification( topic.user, new_comment, CommentNotificationType.TOPIC_REPLY @@ -103,6 +106,8 @@ def post_comment_reply(request: Request, markdown: str) -> dict: ) request.db_session.add(new_comment) + request.db_session.add(LogComment(LogEventType.COMMENT_POST, request, new_comment)) + if parent_comment.user != request.user: notification = CommentNotification( parent_comment.user, new_comment, CommentNotificationType.COMMENT_REPLY @@ -344,3 +349,27 @@ def put_mark_comments_read(request: Request, mark_all_previous: bool) -> Respons _increment_topic_comments_seen(request, notification.comment) return IC_NOOP + + +@ic_view_config(route_name="comment_remove", request_method="PUT", permission="remove") +def put_comment_remove(request: Request) -> Response: + """Remove a comment with Intercooler.""" + comment = request.context + + comment.is_removed = True + request.db_session.add(LogComment(LogEventType.COMMENT_REMOVE, request, comment)) + + return Response("Removed") + + +@ic_view_config( + route_name="comment_remove", request_method="DELETE", permission="remove" +) +def delete_comment_remove(request: Request) -> Response: + """Un-remove a comment with Intercooler.""" + comment = request.context + + comment.is_removed = False + request.db_session.add(LogComment(LogEventType.COMMENT_UNREMOVE, request, comment)) + + return Response("Un-removed") diff --git a/tildes/tildes/views/api/web/topic.py b/tildes/tildes/views/api/web/topic.py index 25052e1..33b74d4 100644 --- a/tildes/tildes/views/api/web/topic.py +++ b/tildes/tildes/views/api/web/topic.py @@ -230,6 +230,28 @@ def patch_move_topic(request: Request, path: str) -> dict: return Response("Moved") +@ic_view_config(route_name="topic_remove", request_method="PUT", permission="remove") +def put_topic_remove(request: Request) -> Response: + """Remove a topic with Intercooler.""" + topic = request.context + + topic.is_removed = True + request.db_session.add(LogTopic(LogEventType.TOPIC_REMOVE, request, topic)) + + return Response("Removed") + + +@ic_view_config(route_name="topic_remove", request_method="DELETE", permission="remove") +def delete_topic_remove(request: Request) -> Response: + """Un-remove a topic with Intercooler.""" + topic = request.context + + topic.is_removed = False + request.db_session.add(LogTopic(LogEventType.TOPIC_UNREMOVE, request, topic)) + + return Response("Un-removed") + + @ic_view_config(route_name="topic_lock", request_method="PUT", permission="lock") def put_topic_lock(request: Request) -> Response: """Lock a topic with Intercooler.""" diff --git a/tildes/tildes/views/exceptions.py b/tildes/tildes/views/exceptions.py index fec2549..e598f6b 100644 --- a/tildes/tildes/views/exceptions.py +++ b/tildes/tildes/views/exceptions.py @@ -1,7 +1,11 @@ """Views used by Pyramid when an exception is raised.""" +from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request -from pyramid.view import forbidden_view_config +from pyramid.view import forbidden_view_config, exception_view_config +from sqlalchemy import cast, desc, func, Text + +from tildes.models.group import Group @forbidden_view_config(xhr=False, renderer="error_403.jinja2") @@ -9,3 +13,21 @@ def forbidden(request: Request) -> dict: """403 Forbidden page.""" request.response.status_int = 403 return {} + + +@exception_view_config( + HTTPNotFound, route_name="group", renderer="error_group_not_found.jinja2" +) +def group_not_found(request: Request) -> dict: + """Show the user a customized 404 page for group names.""" + request.response.status_int = 404 + supplied_name = request.matchdict.get("group_path") + # the 'word_similarity' function here is from the 'pg_trgm' extension + group_suggestions = ( + request.query(Group) + .filter(func.word_similarity(cast(Group.path, Text), supplied_name) > 0) + .order_by(desc(func.word_similarity(cast(Group.path, Text), supplied_name))) + .limit(3) + .all() + ) + return {"group_suggestions": group_suggestions, "supplied_name": supplied_name} diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py index 3f22eba..7f2e53f 100644 --- a/tildes/tildes/views/topic.py +++ b/tildes/tildes/views/topic.py @@ -23,7 +23,7 @@ from tildes.enums import ( from tildes.lib.datetime import SimpleHoursPeriod from tildes.models.comment import Comment, CommentNotification, CommentTree from tildes.models.group import Group -from tildes.models.log import LogTopic +from tildes.models.log import LogComment, LogTopic from tildes.models.topic import Topic, TopicVisit from tildes.models.user import UserGroupSettings from tildes.schemas.comment import CommentSchema @@ -55,6 +55,10 @@ def post_group_topics( topic=new_topic, author=request.user, markdown=markdown ) request.db_session.add(new_comment) + + request.db_session.add( + LogComment(LogEventType.COMMENT_POST, request, new_comment) + ) else: new_topic = Topic.create_text_topic( group=request.context, author=request.user, title=title, markdown=markdown @@ -253,9 +257,11 @@ def get_topic(request: Request, comment_order: CommentSortOption) -> dict: visible_events = ( LogEventType.TOPIC_LOCK, LogEventType.TOPIC_MOVE, + LogEventType.TOPIC_REMOVE, LogEventType.TOPIC_TAG, LogEventType.TOPIC_TITLE_EDIT, LogEventType.TOPIC_UNLOCK, + LogEventType.TOPIC_UNREMOVE, ) log = ( request.query(LogTopic) @@ -293,6 +299,8 @@ def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound: new_comment = Comment(topic=topic, author=request.user, markdown=markdown) request.db_session.add(new_comment) + request.db_session.add(LogComment(LogEventType.COMMENT_POST, request, new_comment)) + if topic.user != request.user and not topic.is_deleted: notification = CommentNotification( topic.user, new_comment, CommentNotificationType.TOPIC_REPLY diff --git a/tildes/tildes/views/user.py b/tildes/tildes/views/user.py index 15d2036..542344e 100644 --- a/tildes/tildes/views/user.py +++ b/tildes/tildes/views/user.py @@ -1,6 +1,6 @@ """Views related to a specific user.""" -from typing import List, Union +from typing import List, Optional, Union from marshmallow.fields import String from marshmallow.validate import OneOf @@ -31,8 +31,8 @@ def _get_user_recent_activity( .join_all_relationships() ) - # include removed comments if the user's looking at their own page - if user == request.user: + # include removed comments if the user's looking at their own page or is an admin + if user == request.user or request.user.is_admin: query = query.include_removed() comments = query.all() @@ -45,8 +45,8 @@ def _get_user_recent_activity( .join_all_relationships() ) - # include removed topics if the user's looking at their own page - if user == request.user: + # include removed topics if the user's looking at their own page or is an admin + if user == request.user or request.user.is_admin: query = query.include_removed() topics = query.all() @@ -67,7 +67,11 @@ def _get_user_recent_activity( {"post_type": String(load_from="type", validate=OneOf(("topic", "comment")))} ) def get_user( - request: Request, after: str, before: str, per_page: int, post_type: str = None + request: Request, + after: str, + before: str, + per_page: int, + post_type: Optional[str] = None, ) -> dict: """Generate the main user history page.""" user = request.context @@ -89,8 +93,8 @@ def get_user( query = query.join_all_relationships() - # include removed posts if the user's looking at their own page - if user == request.user: + # include removed posts if the user's looking at their own page or is an admin + if user == request.user or request.user.is_admin: query = query.include_removed() posts = query.get_page(per_page)