Skip to content

Commit

Permalink
Generic events (pypi#8324)
Browse files Browse the repository at this point in the history
* Generic events

* Update migration to rename table/columns in place

* Use AbstractConcreteBase

* Address feedback from review

* Remove commented out line

* Linting
  • Loading branch information
di authored and domdfcoding committed Jun 7, 2022
1 parent 8385e2d commit 488b145
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 123 deletions.
6 changes: 3 additions & 3 deletions tests/common/db/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import factory

from warehouse.accounts.models import Email, User, UserEvent
from warehouse.accounts.models import Email, User

from .base import WarehouseFactory

Expand Down Expand Up @@ -42,9 +42,9 @@ class Meta:

class UserEventFactory(WarehouseFactory):
class Meta:
model = UserEvent
model = User.Event

user = factory.SubFactory(User)
source = factory.SubFactory(User)


class EmailFactory(WarehouseFactory):
Expand Down
5 changes: 2 additions & 3 deletions tests/common/db/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
JournalEntry,
ProhibitedProjectName,
Project,
ProjectEvent,
Release,
Role,
RoleInvitation,
Expand All @@ -48,9 +47,9 @@ class Meta:

class ProjectEventFactory(WarehouseFactory):
class Meta:
model = ProjectEvent
model = Project.Event

project = factory.SubFactory(ProjectFactory)
source = factory.SubFactory(ProjectFactory)


class DescriptionFactory(WarehouseFactory):
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/accounts/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ def test_query_by_email_when_not_primary(self, db_session):

def test_recent_events(self, db_session):
user = DBUserFactory.create()
recent_event = DBUserEventFactory(user=user, tag="foo", ip_address="0.0.0.0")
recent_event = DBUserEventFactory(source=user, tag="foo", ip_address="0.0.0.0")
stale_event = DBUserEventFactory(
user=user,
source=user,
tag="bar",
ip_address="0.0.0.0",
time=datetime.datetime.now() - datetime.timedelta(days=91),
Expand Down
36 changes: 16 additions & 20 deletions tests/unit/manage/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
File,
JournalEntry,
Project,
ProjectEvent,
Role,
RoleInvitation,
User,
Expand Down Expand Up @@ -2556,12 +2555,9 @@ def test_toggle_2fa_requirement_non_critical(
assert result.status_code == 303
assert result.headers["Location"] == "/foo/bar/"

event = (
db_request.db.query(ProjectEvent)
.join(ProjectEvent.project)
.filter(ProjectEvent.project_id == project.id)
.one()
)
events = project.events
assert len(events) == 1
event = events[0]
assert event.tag == tag
assert event.additional == {"modified_by": db_request.user.username}

Expand Down Expand Up @@ -4460,13 +4456,13 @@ class TestManageProjectHistory:
def test_get(self, db_request):
project = ProjectFactory.create()
older_event = ProjectEventFactory.create(
project=project,
source=project,
tag="fake:event",
ip_address="0.0.0.0",
time=datetime.datetime(2017, 2, 5, 17, 18, 18, 462_634),
)
newer_event = ProjectEventFactory.create(
project=project,
source=project,
tag="fake:event",
ip_address="0.0.0.0",
time=datetime.datetime(2018, 2, 5, 17, 18, 18, 462_634),
Expand Down Expand Up @@ -4510,13 +4506,13 @@ def test_first_page(self, db_request):
total_items = items_per_page + 2
for _ in range(total_items):
ProjectEventFactory.create(
project=project, tag="fake:event", ip_address="0.0.0.0"
source=project, tag="fake:event", ip_address="0.0.0.0"
)
events_query = (
db_request.db.query(ProjectEvent)
.join(ProjectEvent.project)
.filter(ProjectEvent.project_id == project.id)
.order_by(ProjectEvent.time.desc())
db_request.db.query(Project.Event)
.join(Project.Event.source)
.filter(Project.Event.source_id == project.id)
.order_by(Project.Event.time.desc())
)

events_page = SQLAlchemyORMPage(
Expand All @@ -4541,13 +4537,13 @@ def test_last_page(self, db_request):
total_items = items_per_page + 2
for _ in range(total_items):
ProjectEventFactory.create(
project=project, tag="fake:event", ip_address="0.0.0.0"
source=project, tag="fake:event", ip_address="0.0.0.0"
)
events_query = (
db_request.db.query(ProjectEvent)
.join(ProjectEvent.project)
.filter(ProjectEvent.project_id == project.id)
.order_by(ProjectEvent.time.desc())
db_request.db.query(Project.Event)
.join(Project.Event.source)
.filter(Project.Event.source_id == project.id)
.order_by(Project.Event.time.desc())
)

events_page = SQLAlchemyORMPage(
Expand All @@ -4572,7 +4568,7 @@ def test_raises_404_with_out_of_range_page(self, db_request):
total_items = items_per_page + 2
for _ in range(total_items):
ProjectEventFactory.create(
project=project, tag="fake:event", ip_address="0.0.0.0"
source=project, tag="fake:event", ip_address="0.0.0.0"
)

with pytest.raises(HTTPNotFound):
Expand Down
42 changes: 8 additions & 34 deletions warehouse/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@
select,
sql,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.exc import NoResultFound

from warehouse import db
from warehouse.events.models import HasEvents
from warehouse.sitemap.models import SitemapMixin
from warehouse.utils.attrs import make_repr
from warehouse.utils.db.types import TZDateTime
Expand All @@ -57,7 +58,7 @@ class DisableReason(enum.Enum):
AccountFrozen = "account frozen"


class User(SitemapMixin, db.Model):
class User(SitemapMixin, HasEvents, db.Model):

__tablename__ = "users"
__table_args__ = (
Expand Down Expand Up @@ -107,20 +108,6 @@ class User(SitemapMixin, db.Model):
"Macaroon", backref="user", cascade="all, delete-orphan", lazy=True
)

events = orm.relationship(
"UserEvent", backref="user", cascade="all, delete-orphan", lazy=True
)

def record_event(self, *, tag, ip_address, additional):
session = orm.object_session(self)
event = UserEvent(
user=self, tag=tag, ip_address=ip_address, additional=additional
)
session.add(event)
session.flush()

return event

@property
def primary_email(self):
primaries = [x for x in self.emails if x.primary]
Expand Down Expand Up @@ -167,9 +154,11 @@ def recent_events(self):
session = orm.object_session(self)
last_ninety = datetime.datetime.now() - datetime.timedelta(days=90)
return (
session.query(UserEvent)
.filter((UserEvent.user_id == self.id) & (UserEvent.time >= last_ninety))
.order_by(UserEvent.time.desc())
session.query(User.Event)
.filter(
(User.Event.source_id == self.id) & (User.Event.time >= last_ninety)
)
.order_by(User.Event.time.desc())
.all()
)

Expand Down Expand Up @@ -217,21 +206,6 @@ class RecoveryCode(db.Model):
burned = Column(DateTime, nullable=True)


class UserEvent(db.Model):
__tablename__ = "user_events"

user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", deferrable=True, initially="DEFERRED"),
nullable=False,
index=True,
)
tag = Column(String, nullable=False)
time = Column(DateTime, nullable=False, server_default=sql.func.now())
ip_address = Column(String, nullable=False)
additional = Column(JSONB, nullable=True)


class UnverifyReasons(enum.Enum):

SpamComplaint = "spam complaint"
Expand Down
79 changes: 79 additions & 0 deletions warehouse/events/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from sqlalchemy import Column, DateTime, ForeignKey, String, orm, sql
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.declarative import AbstractConcreteBase, declared_attr

from warehouse import db


class Event(AbstractConcreteBase):
tag = Column(String, nullable=False)
time = Column(DateTime, nullable=False, server_default=sql.func.now())
ip_address = Column(String, nullable=False)
additional = Column(JSONB, nullable=True)

@declared_attr
def __tablename__(cls): # noqa: N805
return "_".join([cls.__name__.removesuffix("Event").lower(), "events"])

@declared_attr
def __mapper_args__(cls): # noqa: N805
return (
{"polymorphic_identity": cls.__name__, "concrete": True}
if cls.__name__ != "Event"
else {}
)

@declared_attr
def source_id(cls): # noqa: N805
return Column(
UUID(as_uuid=True),
ForeignKey(
"%s.id" % cls._parent_class.__tablename__,
deferrable=True,
initially="DEFERRED",
),
nullable=False,
)

@declared_attr
def source(cls): # noqa: N805
return orm.relationship(cls._parent_class)

def __init_subclass__(cls, /, parent_class, **kwargs):
cls._parent_class = parent_class
return cls


class HasEvents:
def __init_subclass__(cls, /, **kwargs):
super().__init_subclass__(**kwargs)
cls.Event = type(
f"{cls.__name__}Event", (Event, db.Model), dict(), parent_class=cls
)
return cls

@declared_attr
def events(cls): # noqa: N805
return orm.relationship(cls.Event, cascade="all, delete-orphan", lazy=True)

def record_event(self, *, tag, ip_address, additional=None):
session = orm.object_session(self)
event = self.Event(
source=self, tag=tag, ip_address=ip_address, additional=additional
)
session.add(event)
session.flush()

return event
24 changes: 12 additions & 12 deletions warehouse/locale/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ msgstr ""
msgid "Successful WebAuthn assertion"
msgstr ""

#: warehouse/accounts/views.py:441 warehouse/manage/views.py:814
#: warehouse/accounts/views.py:441 warehouse/manage/views.py:813
msgid "Recovery code accepted. The supplied code cannot be used again."
msgstr ""

Expand Down Expand Up @@ -225,51 +225,51 @@ msgstr ""
msgid "Banner Preview"
msgstr ""

#: warehouse/manage/views.py:245
#: warehouse/manage/views.py:244
msgid "Email ${email_address} added - check your email for a verification link"
msgstr ""

#: warehouse/manage/views.py:762
#: warehouse/manage/views.py:761
msgid "Recovery codes already generated"
msgstr ""

#: warehouse/manage/views.py:763
#: warehouse/manage/views.py:762
msgid "Generating new recovery codes will invalidate your existing codes."
msgstr ""

#: warehouse/manage/views.py:1191
#: warehouse/manage/views.py:1190
msgid ""
"There have been too many attempted OpenID Connect registrations. Try "
"again later."
msgstr ""

#: warehouse/manage/views.py:1872
#: warehouse/manage/views.py:1871
msgid "User '${username}' already has ${role_name} role for project"
msgstr ""

#: warehouse/manage/views.py:1883
#: warehouse/manage/views.py:1882
msgid ""
"User '${username}' does not have a verified primary email address and "
"cannot be added as a ${role_name} for project"
msgstr ""

#: warehouse/manage/views.py:1896
#: warehouse/manage/views.py:1895
msgid "User '${username}' already has an active invite. Please try again later."
msgstr ""

#: warehouse/manage/views.py:1954
#: warehouse/manage/views.py:1953
msgid "Invitation sent to '${username}'"
msgstr ""

#: warehouse/manage/views.py:2001
#: warehouse/manage/views.py:2000
msgid "Could not find role invitation."
msgstr ""

#: warehouse/manage/views.py:2012
#: warehouse/manage/views.py:2011
msgid "Invitation already expired."
msgstr ""

#: warehouse/manage/views.py:2036
#: warehouse/manage/views.py:2035
msgid "Invitation revoked from '${username}'."
msgstr ""

Expand Down
Loading

0 comments on commit 488b145

Please sign in to comment.