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:
- 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

7
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

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:
git.latest:
- 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/
disallow_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true

1
tildes/requirements-to-freeze.txt

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

1
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

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:
"""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."""

6
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;

8
tildes/scss/modules/_empty.scss

@ -2,3 +2,11 @@
background: 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;
}
}
.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):
"""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"

6
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):

3
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

2
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

2
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

58
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}

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

5
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

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_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
)

8
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

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
{% 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 %}
{{ comment.rendered_html|safe }}
@ -160,6 +160,30 @@
>Delete</a></li>
{% 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) %}
<li><a class="post-button" name="reply" data-js-comment-reply-button>Reply</a></li>
{% endif %}

28
tildes/tildes/templates/topic.jinja2

@ -56,7 +56,7 @@
{% 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">
{% if request.has_permission('edit', topic) %}
<li><a class="post-button" name="edit"
@ -135,6 +135,30 @@
{% endif %}
</li>
{% 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>
{% endif %}
@ -142,7 +166,7 @@
<div class="toast toast-warning">This topic is locked. New comments can not be posted.</div>
{% endif %}
{% if topic.num_comments > 0 %}
{% if comments %}
<section class="topic-comments">
<header class="topic-comments-header">
<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 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")

22
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."""

24
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}

10
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

20
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)

Loading…
Cancel
Save