Browse Source

Merge branch 'master' into feature-saved-themes

merge-requests/25/head
Celeo 7 years ago
parent
commit
b4601327ab
  1. 7
      salt/salt/postgresql/site-db.sls
  2. 7
      salt/salt/postgresql/test-db.sls
  3. 4
      salt/salt/redis/modules/rebloom.sls
  4. 36
      tildes/alembic/versions/3fbddcba0e3b_add_comment_remove_and_comment_unremove_.py
  5. 35
      tildes/alembic/versions/6a635773de8f_add_comment_post_to_logeventtype.py
  6. 32
      tildes/alembic/versions/a1708d376252_drop_topics_removed_time_column.py
  7. 58
      tildes/alembic/versions/b3be50625592_add_log_comments_table.py
  8. 36
      tildes/alembic/versions/bcf1406bb6c5_add_admin_tool_for_removing_topics.py
  9. 1
      tildes/mypy.ini
  10. 1
      tildes/requirements-to-freeze.txt
  11. 1
      tildes/requirements.txt
  12. 5
      tildes/scripts/initialize_db.py
  13. 6
      tildes/scss/modules/_comment.scss
  14. 8
      tildes/scss/modules/_empty.scss
  15. 4
      tildes/scss/modules/_form.scss
  16. 16
      tildes/tests/test_topic.py
  17. 6
      tildes/tildes/enums.py
  18. 3
      tildes/tildes/models/comment/comment.py
  19. 2
      tildes/tildes/models/group/group.py
  20. 2
      tildes/tildes/models/log/__init__.py
  21. 58
      tildes/tildes/models/log/log.py
  22. 9
      tildes/tildes/models/topic/topic.py
  23. 5
      tildes/tildes/models/user/user.py
  24. 4
      tildes/tildes/routes.py
  25. 8
      tildes/tildes/schemas/fields.py
  26. 26
      tildes/tildes/templates/error_group_not_found.jinja2
  27. 28
      tildes/tildes/templates/macros/comments.jinja2
  28. 28
      tildes/tildes/templates/topic.jinja2
  29. 31
      tildes/tildes/views/api/web/comment.py
  30. 22
      tildes/tildes/views/api/web/topic.py
  31. 24
      tildes/tildes/views/exceptions.py
  32. 10
      tildes/tildes/views/topic.py
  33. 20
      tildes/tildes/views/user.py

7
salt/salt/postgresql/site-db.sls

@ -39,6 +39,13 @@ site-db-enable-pg_stat_statements:
- require: - require:
- postgres_database: tildes - 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: site-db-pg_hba-lines:
file.accumulated: file.accumulated:
- name: pg_hba_lines - name: pg_hba_lines

7
salt/salt/postgresql/test-db.sls

@ -26,6 +26,13 @@ test-db-enable-intarray:
- require: - require:
- postgres_database: tildes_test - 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: test-db-pg_hba-lines:
file.accumulated: file.accumulated:
- name: pg_hba_lines - name: pg_hba_lines

4
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: rebloom-clone:
git.latest: git.latest:
- name: https://github.com/RedisLabsModules/rebloom - name: https://github.com/RedisLabsModules/rebloom

36
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

35
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

32
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,
),
)

58
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")

36
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

1
tildes/mypy.ini

@ -2,3 +2,4 @@
mypy_path = /opt/tildes/stubs/ mypy_path = /opt/tildes/stubs/
disallow_untyped_defs = true disallow_untyped_defs = true
ignore_missing_imports = true ignore_missing_imports = true
no_implicit_optional = true

1
tildes/requirements-to-freeze.txt

@ -37,6 +37,7 @@ SQLAlchemy
SQLAlchemy-Utils SQLAlchemy-Utils
stripe stripe
testing.redis testing.redis
titlecase
webargs webargs
webtest webtest
zope.sqlalchemy zope.sqlalchemy

1
tildes/requirements.txt

@ -83,6 +83,7 @@ SQLAlchemy-Utils==0.33.3
stripe==2.4.0 stripe==2.4.0
testing.common.database==2.0.3 testing.common.database==2.0.3
testing.redis==1.1.1 testing.redis==1.1.1
titlecase==0.12.0
toml==0.9.4 toml==0.9.4
traitlets==4.3.2 traitlets==4.3.2
transaction==2.2.1 transaction==2.2.1

5
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: def create_tables(connectable: Connectable) -> None:
"""Create the database tables.""" """Create the database tables."""
# tables to skip (due to inheritance or other need to create manually) # tables to skip (due to inheritance or other need to create manually)
excluded_tables = Log.INHERITED_TABLES
excluded_tables = Log.INHERITED_TABLES + ["log"]
tables = [ tables = [
table table
@ -47,6 +47,9 @@ def create_tables(connectable: Connectable) -> None:
] ]
DatabaseModel.metadata.create_all(connectable, tables=tables) 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: def run_sql_scripts_in_dir(path: str, engine: Engine) -> None:
"""Run all sql scripts in a directory.""" """Run all sql scripts in a directory."""

6
tildes/scss/modules/_comment.scss

@ -88,6 +88,12 @@
overflow: auto; overflow: auto;
} }
.comment-removed-warning {
color: $warning-color;
font-weight: bold;
font-size: 0.6rem;
}
.comment-votes { .comment-votes {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: bold; font-weight: bold;

8
tildes/scss/modules/_empty.scss

@ -2,3 +2,11 @@
background: inherit; background: inherit;
color: inherit; color: inherit;
} }
.empty-list {
list-style-type: none;
li {
max-width: unset;
}
}

4
tildes/scss/modules/_form.scss

@ -91,3 +91,7 @@ textarea.form-input {
flex: 4; flex: 4;
} }
} }
.form-search .form-input {
margin-right: 0.4rem;
}

16
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): def test_topic_initial_last_activity_time(text_topic):
"""Ensure last_activity_time is initially the same as created_time.""" """Ensure last_activity_time is initially the same as created_time."""
assert text_topic.last_activity_time == text_topic.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"

6
tildes/tildes/enums.py

@ -48,12 +48,18 @@ class LogEventType(enum.Enum):
USER_LOG_OUT = enum.auto() USER_LOG_OUT = enum.auto()
USER_REGISTER = enum.auto() USER_REGISTER = enum.auto()
COMMENT_POST = enum.auto()
COMMENT_REMOVE = enum.auto()
COMMENT_UNREMOVE = enum.auto()
TOPIC_LOCK = enum.auto() TOPIC_LOCK = enum.auto()
TOPIC_MOVE = enum.auto() TOPIC_MOVE = enum.auto()
TOPIC_POST = enum.auto() TOPIC_POST = enum.auto()
TOPIC_REMOVE = enum.auto()
TOPIC_TAG = enum.auto() TOPIC_TAG = enum.auto()
TOPIC_TITLE_EDIT = enum.auto() TOPIC_TITLE_EDIT = enum.auto()
TOPIC_UNLOCK = enum.auto() TOPIC_UNLOCK = enum.auto()
TOPIC_UNREMOVE = enum.auto()
class TopicSortOption(enum.Enum): class TopicSortOption(enum.Enum):

3
tildes/tildes/models/comment/comment.py

@ -185,6 +185,9 @@ class Comment(DatabaseModel):
# - logged-in users can mark comments read # - logged-in users can mark comments read
acl.append((Allow, Authenticated, "mark_read")) acl.append((Allow, Authenticated, "mark_read"))
# tools that require specifically granted permissions
acl.append((Allow, "admin", "remove"))
acl.append(DENY_ALL) acl.append(DENY_ALL)
return acl return acl

2
tildes/tildes/models/group/group.py

@ -62,7 +62,7 @@ class Group(DatabaseModel):
"""Order groups by their string representation.""" """Order groups by their string representation."""
return str(self) < str(other) 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.""" """Create a new group."""
self.path = Ltree(path) self.path = Ltree(path)
self.short_description = short_desc self.short_description = short_desc

2
tildes/tildes/models/log/__init__.py

@ -1,3 +1,3 @@
"""Contains models related to logs.""" """Contains models related to logs."""
from .log import Log, LogTopic
from .log import Log, LogComment, LogTopic

58
tildes/tildes/models/log/log.py

@ -13,6 +13,7 @@ from sqlalchemy.sql.expression import text
from tildes.enums import LogEventType from tildes.enums import LogEventType
from tildes.models import DatabaseModel from tildes.models import DatabaseModel
from tildes.models.comment import Comment
from tildes.models.topic import Topic from tildes.models.topic import Topic
@ -65,7 +66,7 @@ class Log(DatabaseModel, BaseLog):
__tablename__ = "log" __tablename__ = "log"
INHERITED_TABLES = ["log_topics"]
INHERITED_TABLES = ["log_comments", "log_topics"]
def __init__( def __init__(
self, self,
@ -86,6 +87,35 @@ class Log(DatabaseModel, BaseLog):
self.info = info 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): class LogTopic(DatabaseModel, BaseLog):
"""Model for a log entry related to a specific topic.""" """Model for a log entry related to a specific topic."""
@ -112,6 +142,7 @@ class LogTopic(DatabaseModel, BaseLog):
def __str__(self) -> str: def __str__(self) -> str:
"""Return a string representation of the log event.""" """Return a string representation of the log event."""
# pylint: disable=too-many-return-statements
if self.event_type == LogEventType.TOPIC_TAG: if self.event_type == LogEventType.TOPIC_TAG:
return self._tag_event_description() return self._tag_event_description()
elif self.event_type == LogEventType.TOPIC_MOVE: elif self.event_type == LogEventType.TOPIC_MOVE:
@ -120,8 +151,12 @@ class LogTopic(DatabaseModel, BaseLog):
return f"moved from ~{old_group} to ~{new_group}" return f"moved from ~{old_group} to ~{new_group}"
elif self.event_type == LogEventType.TOPIC_LOCK: elif self.event_type == LogEventType.TOPIC_LOCK:
return "locked comments" return "locked comments"
elif self.event_type == LogEventType.TOPIC_REMOVE:
return "removed"
elif self.event_type == LogEventType.TOPIC_UNLOCK: elif self.event_type == LogEventType.TOPIC_UNLOCK:
return "unlocked comments" return "unlocked comments"
elif self.event_type == LogEventType.TOPIC_UNREMOVE:
return "un-removed"
elif self.event_type == LogEventType.TOPIC_TITLE_EDIT: elif self.event_type == LogEventType.TOPIC_TITLE_EDIT:
old_title = self.info["old"] # noqa old_title = self.info["old"] # noqa
new_title = self.info["new"] # 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"} 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)") 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 # duplicate all the indexes/constraints from the base log table
for table in Log.INHERITED_TABLES: for table in Log.INHERITED_TABLES:
pk_name = naming["pk"] % {"table_name": table} pk_name = naming["pk"] % {"table_name": table}

9
tildes/tildes/models/topic/topic.py

@ -20,6 +20,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import deferred, relationship from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
from titlecase import titlecase
from tildes.enums import TopicType from tildes.enums import TopicType
from tildes.lib.database import ArrayOfLtree from tildes.lib.database import ArrayOfLtree
@ -91,7 +92,6 @@ class Topic(DatabaseModel):
is_removed: bool = Column( is_removed: bool = Column(
Boolean, nullable=False, server_default="false", index=True Boolean, nullable=False, server_default="false", index=True
) )
removed_time: Optional[datetime] = Column(TIMESTAMP(timezone=True))
title: str = Column( title: str = Column(
Text, Text,
CheckConstraint(f"LENGTH(title) <= {TITLE_MAX_LENGTH}", name="title_length"), CheckConstraint(f"LENGTH(title) <= {TITLE_MAX_LENGTH}", name="title_length"),
@ -172,6 +172,11 @@ class Topic(DatabaseModel):
new_topic = cls() new_topic = cls()
new_topic.group_id = group.group_id new_topic.group_id = group.group_id
new_topic.user_id = author.user_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 new_topic.title = title
return new_topic return new_topic
@ -276,6 +281,8 @@ class Topic(DatabaseModel):
# tools that require specifically granted permissions # tools that require specifically granted permissions
acl.append((Allow, "admin", "lock")) acl.append((Allow, "admin", "lock"))
acl.append((Allow, "admin", "remove"))
acl.append((Allow, "admin", "move")) acl.append((Allow, "admin", "move"))
acl.append((Allow, "topic.move", "move")) acl.append((Allow, "topic.move", "move"))

5
tildes/tildes/models/user/user.py

@ -219,3 +219,8 @@ class User(DatabaseModel):
return self.permissions return self.permissions
raise ValueError("Unknown permissions format") raise ValueError("Unknown permissions format")
@property
def is_admin(self) -> bool:
"""Return whether the user has admin permissions."""
return "admin" in self.auth_principals

4
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_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_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_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_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("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", "/comments/{comment_id36}", factory=comment_by_id36)
add_ic_route(
"comment_remove", "/comments/{comment_id36}/remove", factory=comment_by_id36
)
add_ic_route( add_ic_route(
"comment_replies", "/comments/{comment_id36}/replies", factory=comment_by_id36 "comment_replies", "/comments/{comment_id36}/replies", factory=comment_by_id36
) )

8
tildes/tildes/schemas/fields.py

@ -16,7 +16,9 @@ from tildes.lib.string import simplify_string
class Enum(Field): class Enum(Field):
"""Field for a native Python Enum (or subclasses).""" """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.""" """Initialize the field with an optional enum class."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._enum_class = enum_class self._enum_class = enum_class
@ -77,7 +79,7 @@ class Markdown(Field):
DEFAULT_MAX_LENGTH = 50_000 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.""" """Initialize the field with a length validator."""
if not max_length: if not max_length:
max_length = self.DEFAULT_MAX_LENGTH max_length = self.DEFAULT_MAX_LENGTH
@ -115,7 +117,7 @@ class SimpleString(Field):
DEFAULT_MAX_LENGTH = 200 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.""" """Initialize the field with a length validator."""
if not max_length: if not max_length:
max_length = self.DEFAULT_MAX_LENGTH max_length = self.DEFAULT_MAX_LENGTH

26
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 %}
<div class="empty">
<h2 class="empty-title">No group named '{{ supplied_name }}'</h2>
{% if group_suggestions %}
<p class="empty-subtitle">Did you mean one of these groups instead?</p>
<ul class="empty-list">
{% for group in group_suggestions %}
<li>{{ group_linked(group) }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="empty-action">
<a href="/groups" class="btn btn-primary">Browse the list of groups</a>
</div>
</div>
{% endblock %}

28
tildes/tildes/templates/macros/comments.jinja2

@ -99,8 +99,8 @@
data-js-external-links-new-tabs data-js-external-links-new-tabs
{% endif %} {% endif %}
> >
{% if comment.is_removed and 'admin' in request.effective_principals %}
<p class="text-warning">Comment removed</p>
{% if comment.is_removed %}
<p class="comment-removed-warning">This comment has been removed and is not visible to other users</p>
{% endif %} {% endif %}
{{ comment.rendered_html|safe }} {{ comment.rendered_html|safe }}
@ -160,6 +160,30 @@
>Delete</a></li> >Delete</a></li>
{% endif %} {% endif %}
{% if request.has_permission("remove", comment) %}
<li>
{% if not comment.is_removed %}
<a class="post-button"
data-ic-put-to="{{ request.route_url(
'ic_comment_remove',
comment_id36=comment.comment_id36,
) }}"
data-ic-replace-target="true"
data-ic-confirm="Remove this comment?"
>Remove</a>
{% else %}
<a class="post-button"
data-ic-delete-from="{{ request.route_url(
'ic_comment_remove',
comment_id36=comment.comment_id36,
) }}"
data-ic-replace-target="true"
data-ic-confirm="Un-remove this comment?"
>Un-remove</a>
{% endif %}
</li>
{% endif %}
{% if request.has_permission('reply', comment) %} {% if request.has_permission('reply', comment) %}
<li><a class="post-button" name="reply" data-js-comment-reply-button>Reply</a></li> <li><a class="post-button" name="reply" data-js-comment-reply-button>Reply</a></li>
{% endif %} {% endif %}

28
tildes/tildes/templates/topic.jinja2

@ -56,7 +56,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if request.has_any_permission(('edit', 'delete', 'tag', 'lock', 'move', 'edit_title'), topic) %}
{% if request.has_any_permission(('edit', 'delete', 'tag', 'lock', 'move', 'edit_title', 'remove'), topic) %}
<menu class="post-buttons"> <menu class="post-buttons">
{% if request.has_permission('edit', topic) %} {% if request.has_permission('edit', topic) %}
<li><a class="post-button" name="edit" <li><a class="post-button" name="edit"
@ -135,6 +135,30 @@
{% endif %} {% endif %}
</li> </li>
{% endif %} {% endif %}
{% if request.has_permission("remove", topic) %}
<li>
{% if not topic.is_removed %}
<a class="post-button"
data-ic-put-to="{{ request.route_url(
'ic_topic_remove',
topic_id36=topic.topic_id36,
) }}"
data-ic-replace-target="true"
data-ic-confirm="Remove this topic?"
>Remove</a>
{% else %}
<a class="post-button"
data-ic-delete-from="{{ request.route_url(
'ic_topic_remove',
topic_id36=topic.topic_id36,
) }}"
data-ic-replace-target="true"
data-ic-confirm="Un-remove this topic?"
>Un-remove</a>
{% endif %}
</li>
{% endif %}
</menu> </menu>
{% endif %} {% endif %}
@ -142,7 +166,7 @@
<div class="toast toast-warning">This topic is locked. New comments can not be posted.</div> <div class="toast toast-warning">This topic is locked. New comments can not be posted.</div>
{% endif %} {% endif %}
{% if topic.num_comments > 0 %}
{% if comments %}
<section class="topic-comments"> <section class="topic-comments">
<header class="topic-comments-header"> <header class="topic-comments-header">
<h2> <h2>

31
tildes/tildes/views/api/web/comment.py

@ -10,9 +10,10 @@ from sqlalchemy.orm.exc import FlushError
from webargs.pyramidparser import use_kwargs from webargs.pyramidparser import use_kwargs
from zope.sqlalchemy import mark_changed 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.lib.datetime import utc_now
from tildes.models.comment import Comment, CommentNotification, CommentTag, CommentVote from tildes.models.comment import Comment, CommentNotification, CommentTag, CommentVote
from tildes.models.log import LogComment
from tildes.models.topic import TopicVisit from tildes.models.topic import TopicVisit
from tildes.schemas.comment import CommentSchema, CommentTagSchema from tildes.schemas.comment import CommentSchema, CommentTagSchema
from tildes.views import IC_NOOP 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) new_comment = Comment(topic=topic, author=request.user, markdown=markdown)
request.db_session.add(new_comment) 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: if topic.user != request.user and not topic.is_deleted:
notification = CommentNotification( notification = CommentNotification(
topic.user, new_comment, CommentNotificationType.TOPIC_REPLY 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(new_comment)
request.db_session.add(LogComment(LogEventType.COMMENT_POST, request, new_comment))
if parent_comment.user != request.user: if parent_comment.user != request.user:
notification = CommentNotification( notification = CommentNotification(
parent_comment.user, new_comment, CommentNotificationType.COMMENT_REPLY 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) _increment_topic_comments_seen(request, notification.comment)
return IC_NOOP 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")

22
tildes/tildes/views/api/web/topic.py

@ -230,6 +230,28 @@ def patch_move_topic(request: Request, path: str) -> dict:
return Response("Moved") 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") @ic_view_config(route_name="topic_lock", request_method="PUT", permission="lock")
def put_topic_lock(request: Request) -> Response: def put_topic_lock(request: Request) -> Response:
"""Lock a topic with Intercooler.""" """Lock a topic with Intercooler."""

24
tildes/tildes/views/exceptions.py

@ -1,7 +1,11 @@
"""Views used by Pyramid when an exception is raised.""" """Views used by Pyramid when an exception is raised."""
from pyramid.httpexceptions import HTTPNotFound
from pyramid.request import Request 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") @forbidden_view_config(xhr=False, renderer="error_403.jinja2")
@ -9,3 +13,21 @@ def forbidden(request: Request) -> dict:
"""403 Forbidden page.""" """403 Forbidden page."""
request.response.status_int = 403 request.response.status_int = 403
return {} 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}

10
tildes/tildes/views/topic.py

@ -23,7 +23,7 @@ from tildes.enums import (
from tildes.lib.datetime import SimpleHoursPeriod from tildes.lib.datetime import SimpleHoursPeriod
from tildes.models.comment import Comment, CommentNotification, CommentTree from tildes.models.comment import Comment, CommentNotification, CommentTree
from tildes.models.group import Group 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.topic import Topic, TopicVisit
from tildes.models.user import UserGroupSettings from tildes.models.user import UserGroupSettings
from tildes.schemas.comment import CommentSchema from tildes.schemas.comment import CommentSchema
@ -55,6 +55,10 @@ def post_group_topics(
topic=new_topic, author=request.user, markdown=markdown topic=new_topic, author=request.user, markdown=markdown
) )
request.db_session.add(new_comment) request.db_session.add(new_comment)
request.db_session.add(
LogComment(LogEventType.COMMENT_POST, request, new_comment)
)
else: else:
new_topic = Topic.create_text_topic( new_topic = Topic.create_text_topic(
group=request.context, author=request.user, title=title, markdown=markdown 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 = ( visible_events = (
LogEventType.TOPIC_LOCK, LogEventType.TOPIC_LOCK,
LogEventType.TOPIC_MOVE, LogEventType.TOPIC_MOVE,
LogEventType.TOPIC_REMOVE,
LogEventType.TOPIC_TAG, LogEventType.TOPIC_TAG,
LogEventType.TOPIC_TITLE_EDIT, LogEventType.TOPIC_TITLE_EDIT,
LogEventType.TOPIC_UNLOCK, LogEventType.TOPIC_UNLOCK,
LogEventType.TOPIC_UNREMOVE,
) )
log = ( log = (
request.query(LogTopic) 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) new_comment = Comment(topic=topic, author=request.user, markdown=markdown)
request.db_session.add(new_comment) 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: if topic.user != request.user and not topic.is_deleted:
notification = CommentNotification( notification = CommentNotification(
topic.user, new_comment, CommentNotificationType.TOPIC_REPLY topic.user, new_comment, CommentNotificationType.TOPIC_REPLY

20
tildes/tildes/views/user.py

@ -1,6 +1,6 @@
"""Views related to a specific user.""" """Views related to a specific user."""
from typing import List, Union
from typing import List, Optional, Union
from marshmallow.fields import String from marshmallow.fields import String
from marshmallow.validate import OneOf from marshmallow.validate import OneOf
@ -31,8 +31,8 @@ def _get_user_recent_activity(
.join_all_relationships() .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() query = query.include_removed()
comments = query.all() comments = query.all()
@ -45,8 +45,8 @@ def _get_user_recent_activity(
.join_all_relationships() .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() query = query.include_removed()
topics = query.all() topics = query.all()
@ -67,7 +67,11 @@ def _get_user_recent_activity(
{"post_type": String(load_from="type", validate=OneOf(("topic", "comment")))} {"post_type": String(load_from="type", validate=OneOf(("topic", "comment")))}
) )
def get_user( 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: ) -> dict:
"""Generate the main user history page.""" """Generate the main user history page."""
user = request.context user = request.context
@ -89,8 +93,8 @@ def get_user(
query = query.join_all_relationships() 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() query = query.include_removed()
posts = query.get_page(per_page) posts = query.get_page(per_page)

Loading…
Cancel
Save