Skip to content

Commit 88e02db

Browse files
authored
group ownership fe + be changes (#596)
1 parent 6b9bb7a commit 88e02db

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1158
-149
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""add group ownership related columns
2+
3+
Revision ID: 2518bf648ffd
4+
Revises: 1a5a9bffcad4
5+
Create Date: 2025-03-17 07:27:40.281993
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "2518bf648ffd"
17+
down_revision: Union[str, None] = "1a5a9bffcad4"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
# custom fields
25+
op.add_column("custom_fields", sa.Column("group_id", sa.Uuid(), nullable=True))
26+
op.alter_column("custom_fields", "user_id", existing_type=sa.UUID(), nullable=True)
27+
op.create_foreign_key(
28+
"custom_fields_group_id_fkey", "custom_fields", "groups", ["group_id"], ["id"]
29+
)
30+
op.create_check_constraint(
31+
constraint_name="check__user_id_not_null__or__group_id_not_null",
32+
table_name="custom_fields",
33+
condition="user_id IS NOT NULL OR group_id IS NOT NULL",
34+
)
35+
# document types
36+
op.add_column("document_types", sa.Column("group_id", sa.Uuid(), nullable=True))
37+
op.alter_column("document_types", "user_id", existing_type=sa.UUID(), nullable=True)
38+
op.drop_constraint(
39+
"unique document type per user", "document_types", type_="unique"
40+
)
41+
op.create_unique_constraint(
42+
"unique document type per user/group",
43+
"document_types",
44+
["name", "user_id", "group_id"],
45+
)
46+
op.create_foreign_key(
47+
"document_types_group_id_fkey", "document_types", "groups", ["group_id"], ["id"]
48+
)
49+
op.create_check_constraint(
50+
constraint_name="check__user_id_not_null__or__group_id_not_null",
51+
table_name="document_types",
52+
condition="user_id IS NOT NULL OR group_id IS NOT NULL",
53+
)
54+
#### groups
55+
op.add_column("groups", sa.Column("home_folder_id", sa.Uuid(), nullable=True))
56+
op.add_column("groups", sa.Column("inbox_folder_id", sa.Uuid(), nullable=True))
57+
op.create_foreign_key(
58+
"groups_inbox_folder_id_fkey",
59+
"groups",
60+
"folders",
61+
["inbox_folder_id"],
62+
["node_id"],
63+
ondelete="CASCADE",
64+
deferrable=True,
65+
)
66+
op.create_foreign_key(
67+
"groups_home_folder_id_fkey",
68+
"groups",
69+
"folders",
70+
["home_folder_id"],
71+
["node_id"],
72+
ondelete="CASCADE",
73+
deferrable=True,
74+
)
75+
### nodes
76+
op.add_column("nodes", sa.Column("group_id", sa.Uuid(), nullable=True))
77+
op.alter_column("nodes", "user_id", existing_type=sa.UUID(), nullable=True)
78+
op.drop_constraint("unique title per parent per user", "nodes", type_="unique")
79+
op.create_unique_constraint(
80+
"unique title per parent per user/group",
81+
"nodes",
82+
["parent_id", "title", "user_id", "group_id"],
83+
)
84+
op.create_foreign_key(
85+
"nodes_group_id_fkey",
86+
"nodes",
87+
"groups",
88+
["group_id"],
89+
["id"],
90+
ondelete="CASCADE",
91+
use_alter=True,
92+
)
93+
op.create_check_constraint(
94+
constraint_name="check__user_id_not_null__or__group_id_not_null",
95+
table_name="nodes",
96+
condition="user_id IS NOT NULL OR group_id IS NOT NULL",
97+
)
98+
99+
# tags
100+
op.add_column("tags", sa.Column("group_id", sa.Uuid(), nullable=True))
101+
op.alter_column("tags", "user_id", existing_type=sa.UUID(), nullable=True)
102+
op.drop_constraint("unique tag name per user", "tags", type_="unique")
103+
op.create_unique_constraint(
104+
"unique tag name per user/group", "tags", ["name", "user_id", "group_id"]
105+
)
106+
op.create_foreign_key(None, "tags", "groups", ["group_id"], ["id"])
107+
op.create_check_constraint(
108+
constraint_name="check__user_id_not_null__or__group_id_not_null",
109+
table_name="tags",
110+
condition="user_id IS NOT NULL OR group_id IS NOT NULL",
111+
)
112+
# ### end Alembic commands ###
113+
114+
115+
def downgrade() -> None:
116+
# ### commands auto generated by Alembic - please adjust! ###
117+
118+
# tags
119+
op.drop_constraint("unique tag name per user/group", "tags", type_="unique")
120+
op.create_unique_constraint("unique tag name per user", "tags", ["name", "user_id"])
121+
op.alter_column("tags", "user_id", existing_type=sa.UUID(), nullable=False)
122+
op.drop_column("tags", "group_id")
123+
124+
# nodes
125+
op.drop_constraint("nodes_group_id_fkey", "nodes", type_="foreignkey")
126+
op.drop_constraint(
127+
"unique title per parent per user/group", "nodes", type_="unique"
128+
)
129+
op.create_unique_constraint(
130+
"unique title per parent per user", "nodes", ["parent_id", "title", "user_id"]
131+
)
132+
op.alter_column("nodes", "user_id", existing_type=sa.UUID(), nullable=False)
133+
op.drop_column("nodes", "group_id")
134+
# groups
135+
op.drop_constraint("groups_home_folder_id_fkey", "groups", type_="foreignkey")
136+
op.drop_constraint("groups_inbox_folder_id_fkey", "groups", type_="foreignkey")
137+
op.drop_column("groups", "inbox_folder_id")
138+
op.drop_column("groups", "home_folder_id")
139+
140+
# document_types
141+
op.drop_constraint(
142+
"document_types_group_id_fkey", "document_types", type_="foreignkey"
143+
)
144+
op.drop_constraint(
145+
"unique document type per user/group", "document_types", type_="unique"
146+
)
147+
op.create_unique_constraint(
148+
"unique document type per user", "document_types", ["name", "user_id"]
149+
)
150+
op.alter_column(
151+
"document_types", "user_id", existing_type=sa.UUID(), nullable=False
152+
)
153+
op.drop_column("document_types", "group_id")
154+
155+
# custom_fields
156+
op.drop_constraint(
157+
"custom_fields_group_id_fkey", "custom_fields", type_="foreignkey"
158+
)
159+
op.alter_column("custom_fields", "user_id", existing_type=sa.UUID(), nullable=False)
160+
op.drop_column("custom_fields", "group_id")
161+
# op.drop_constraint(
162+
# "check__user_id_not_null__or__group_id_not_null",
163+
# "custom_fields",
164+
# type_="check",
165+
# )
166+
# ### end Alembic commands ###
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""add group flags: delete_me and delete_special_folders
2+
3+
Revision ID: 973801cf0c71
4+
Revises: 2518bf648ffd
5+
Create Date: 2025-03-17 08:38:41.676748
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '973801cf0c71'
16+
down_revision: Union[str, None] = '2518bf648ffd'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.add_column('groups', sa.Column('delete_me', sa.Boolean(), nullable=True))
24+
op.add_column('groups', sa.Column('delete_special_folders', sa.Boolean(), nullable=True))
25+
# ### end Alembic commands ###
26+
27+
28+
def downgrade() -> None:
29+
# ### commands auto generated by Alembic - please adjust! ###
30+
op.drop_column('groups', 'delete_special_folders')
31+
op.drop_column('groups', 'delete_me')
32+
# ### end Alembic commands ###

papermerge/core/db/common.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from typing import List, Tuple, Iterator
33
from uuid import UUID
44

5-
from sqlalchemy import text, select
5+
from sqlalchemy import text, select, exists
6+
from sqlalchemy.orm import aliased
67
from papermerge.core.db.engine import Session
78
from papermerge.core.features.nodes.db import orm
9+
from papermerge.core.features.groups.db import orm as groups_orm
810

911

1012
def get_ancestors(
@@ -103,3 +105,38 @@ def get_descendants(
103105
result = db_session.execute(stmt)
104106

105107
return [(row.id, row.title) for row in result]
108+
109+
110+
def has_node_perm(db_session: Session, node_id: UUID, user_id: UUID) -> bool:
111+
"""
112+
Is <node_id> is owned by <user_id> or by one of the groups to which
113+
<user_id> belongs?
114+
115+
SELECT EXISTS(
116+
SELECT nodes.id
117+
FROM nodes
118+
WHERE id = <node_id> AND
119+
(
120+
user_id = <user_id> OR
121+
group_id IN (
122+
SELECT ug.group_id
123+
FROM users_groups ug
124+
WHERE ug.user_id = <user_id>
125+
)
126+
)
127+
)
128+
"""
129+
UserGroupAlias = aliased(groups_orm.user_groups_association)
130+
subquery = select(UserGroupAlias.c.group_id).where(
131+
UserGroupAlias.c.user_id == user_id
132+
)
133+
exists_query = exists(
134+
select(orm.Node.id).where(
135+
(orm.Node.id == node_id)
136+
& ((orm.Node.user_id == user_id) | (orm.Node.group_id.in_(subquery)))
137+
)
138+
).select()
139+
140+
result = db_session.execute(exists_query).scalar()
141+
142+
return result

papermerge/core/dbapi.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,5 @@
7171
"delete_role",
7272
"create_role",
7373
"sync_perms",
74-
"get_perms"
74+
"get_perms",
7575
]

papermerge/core/features/conftest.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,19 @@ def _make_tax(title: str, user: orm.User, parent=None):
579579

580580
@pytest.fixture()
581581
def make_group(db_session):
582-
def _maker(name: str):
583-
group = orm.Group(name=name)
584-
db_session.add(group)
585-
db_session.commit()
582+
def _maker(name: str, with_special_folders=False):
583+
if with_special_folders:
584+
group = orm.Group(name=name)
585+
uid = uuid.uuid4()
586+
db_session.add(group)
587+
folder = orm.Folder(id=uid, title="home", group=group, lang="de")
588+
db_session.add(folder)
589+
group.home_folder_id = uid
590+
db_session.commit()
591+
else:
592+
group = orm.Group(name=name)
593+
db_session.add(group)
594+
db_session.commit()
586595
return group
587596

588597
return _maker

papermerge/core/features/custom_fields/db/orm.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from uuid import UUID
33
from decimal import Decimal
44

5-
from sqlalchemy import ForeignKey, func
5+
from sqlalchemy import ForeignKey, func, CheckConstraint
66
from sqlalchemy.orm import Mapped, mapped_column
77

88
from papermerge.core.db.base import Base
@@ -16,7 +16,19 @@ class CustomField(Base):
1616
type: Mapped[str]
1717
extra_data: Mapped[str] = mapped_column(nullable=True)
1818
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
19-
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
19+
user_id: Mapped[UUID] = mapped_column(
20+
ForeignKey("users.id", name="custom_fields_user_id_fkey"), nullable=True
21+
)
22+
group_id: Mapped[UUID] = mapped_column(
23+
ForeignKey("groups.id", name="custom_fields_group_id_fkey"), nullable=True
24+
)
25+
26+
__table_args__ = (
27+
CheckConstraint(
28+
"user_id IS NOT NULL OR group_id IS NOT NULL",
29+
name="check__user_id_not_null__or__group_id_not_null",
30+
),
31+
)
2032

2133

2234
class CustomFieldValue(Base):

0 commit comments

Comments
 (0)