Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic events #8324

Merged
merged 6 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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