Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
843e161
Filter out specific filenames when cloning project
harminius Dec 11, 2025
33183c5
test for files exclution when cloning
harminius Dec 11, 2025
22b481f
fix tests
harminius Dec 11, 2025
eea191c
Add fullname to v2 GET collaborators
harminius Dec 12, 2025
0f47148
Introduce UserSummary component and adjust project collaborators
harminius Dec 12, 2025
08e99c0
Merge pull request #546 from MerginMaps/exlude_clone_filenames
MarcelGeo Dec 15, 2025
2ebcae8
Integrate last_signed_in in admin
harminius Dec 18, 2025
ebe286a
add last_signed_in to API response
harminius Jan 9, 2026
ed67787
introduce user card component
harminius Jan 9, 2026
3204c2c
return v2 project info from project versions
MarcelGeo Jan 12, 2026
b52d8c8
Merge pull request #555 from MerginMaps/v2_project_info
MarcelGeo Jan 12, 2026
72bdc7e
Use fullname for avatar
harminius Jan 13, 2026
bc8ddf7
Merge remote-tracking branch 'origin/develop' into integrate_last_sig…
harminius Jan 13, 2026
424c6bc
Make fullname and last_signed_in optional
harminius Jan 13, 2026
a046c75
Get last signed in when missing in admin
harminius Jan 14, 2026
768d18e
Rename fullname to name
harminius Jan 14, 2026
f67d86e
Ensure we have user when rendering user details
harminius Jan 14, 2026
1ac849f
rename fullname to name 2
harminius Jan 14, 2026
8ada302
rm last_signed_in from collaborators
harminius Jan 14, 2026
e2fba85
UserSummary width to auto
harminius Jan 14, 2026
513d9b6
Gap in UserSummary to 10px
harminius Jan 14, 2026
900f39c
Gap 12px
harminius Jan 15, 2026
d57b3a8
Use vue class for spacing
harminius Jan 15, 2026
1d54e3e
add props to custom usage of UserSummary
harminius Jan 15, 2026
0b99238
Merge pull request #551 from MerginMaps/integrate_last_sign_in
MarcelGeo Jan 15, 2026
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
5 changes: 5 additions & 0 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,11 @@ components:
type: string
format: date-time
example: 2023-07-30T08:47:58Z
last_signed_in:
nullable: true
type: string
format: date-time
example: 2025-12-18T08:47:58Z
profile:
$ref: "#/components/schemas/UserProfile"
PaginatedUsers:
Expand Down
3 changes: 3 additions & 0 deletions server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,9 @@ def register_user(): # pylint: disable=W0613,W0612
@auth_required(permissions=["admin"])
def get_user(username):
user = User.query.filter(User.username == username).first_or_404()
if not user.last_signed_in:
last_signed_in = LoginHistory.get_users_last_signed_in([user.id])
user.last_signed_in = last_signed_in.get(user.id)
data = UserSchema().dump(user)
return data, 200

Expand Down
2 changes: 1 addition & 1 deletion server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ class UserProfile(db.Model):
),
)

def name(self):
def name(self) -> Optional[str]:
return f'{self.first_name if self.first_name else ""} {self.last_name if self.last_name else ""}'.strip()


Expand Down
3 changes: 2 additions & 1 deletion server/mergin/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class UserProfileSchema(ma.SQLAlchemyAutoSchema):
name = ma.Function(
lambda obj: f'{obj.first_name if obj.first_name else ""} {obj.last_name if obj.last_name else ""}'.strip(),
lambda obj: obj.name(),
dump_only=True,
)
storage = fields.Method("get_storage", dump_only=True)
Expand Down Expand Up @@ -70,6 +70,7 @@ class Meta:
"profile",
"scheduled_removal",
"registration_date",
"last_signed_in",
)
load_instance = True

Expand Down
3 changes: 3 additions & 0 deletions server/mergin/sync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ class Configuration(object):
UPLOAD_CHUNKS_EXPIRATION = config(
"UPLOAD_CHUNKS_EXPIRATION", default=86400, cast=int
)
EXCLUDED_CLONE_FILENAMES = config(
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
)
2 changes: 2 additions & 0 deletions server/mergin/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ def get_member(self, user_id: int) -> Optional[ProjectMember]:
project_role=ProjectRole(member.role),
workspace_role=self.workspace.get_user_role(member.user),
role=ProjectPermissions.get_user_project_role(self, member.user),
name=member.user.profile.name(),
)

def members_by_role(self, role: ProjectRole) -> List[int]:
Expand Down Expand Up @@ -364,6 +365,7 @@ class ProjectMember:
workspace_role: WorkspaceRole
project_role: Optional[ProjectRole]
role: ProjectRole
name: Optional[str]


@dataclass
Expand Down
6 changes: 6 additions & 0 deletions server/mergin/sync/private_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,12 @@ components:
type: string
format: date-time
example: 2018-11-30T08:47:58.636074Z
last_signed_in:
description: Present only for type `member`
nullable: true
type: string
format: date-time
example: 2025-12-18T08:47:58Z
ProjectAccessUpdated:
type: object
properties:
Expand Down
7 changes: 6 additions & 1 deletion server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1136,9 +1136,12 @@ def clone_project(namespace, project_name): # noqa: E501
)
p.updated = datetime.utcnow()
db.session.add(p)
files_to_exclude = current_app.config.get("EXCLUDED_CLONE_FILENAMES", [])

try:
p.storage.initialize(template_project=cloned_project)
p.storage.initialize(
template_project=cloned_project, excluded_files=files_to_exclude
)
except InitializationError as e:
abort(400, f"Failed to clone project: {str(e)}")

Expand All @@ -1149,6 +1152,8 @@ def clone_project(namespace, project_name): # noqa: E501
# transform source files to new uploaded files
file_changes = []
for file in cloned_project.files:
if os.path.basename(file.path) in files_to_exclude:
continue
file_changes.append(
ProjectFileChange(
file.path,
Expand Down
86 changes: 6 additions & 80 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/Project"
$ref: "#/components/schemas/ProjectDetail"
"204":
$ref: "#/components/responses/NoContent"
"400":
Expand Down Expand Up @@ -528,6 +528,10 @@ components:
$ref: "#/components/schemas/ProjectRole"
role:
$ref: "#/components/schemas/Role"
name:
nullable: true
type: string
example: John Doe
ProjectDetail:
type: object
required:
Expand Down Expand Up @@ -586,7 +590,7 @@ components:
description: List of files in the project
items:
allOf:
- $ref: '#/components/schemas/File'
- $ref: "#/components/schemas/File"
- type: object
properties:
mtime:
Expand Down Expand Up @@ -768,84 +772,6 @@ components:
type: string
format: date-time
example: 2019-02-26T08:47:58.636074Z
Project:
type: object
required:
- name
properties:
id:
type: string
example: f9ef87ac-1dae-48ab-85cb-062a4784fb83
description: Project UUID
name:
type: string
example: mergin
namespace:
type: string
example: mergin
creator:
nullable: true
type: integer
example: 1
description: Project creator ID
created:
type: string
format: date-time
example: 2018-11-30T08:47:58.636074Z
updated:
type: string
nullable: true
format: date-time
example: 2018-11-30T08:47:58.636074Z
description: Last modified
version:
type: string
nullable: true
example: v2
description: Last project version
disk_usage:
type: integer
example: 25324373
description: Project size in bytes
permissions:
type: object
properties:
delete:
type: boolean
example: false
update:
type: boolean
example: false
upload:
type: boolean
example: true
tags:
type: array
nullable: true
items:
$ref: "#/components/schemas/MerginTag"
uploads:
type: array
nullable: true
items:
type: string
example: 669b838e-a30b-4338-b2b6-3da144742a82
description: UUID for ongoing upload
access:
$ref: "#/components/schemas/Access"
files:
type: array
items:
allOf:
- $ref: "#/components/schemas/FileInfo"
role:
nullable: true
type: string
enum:
- reader
- editor
- writer
- owner
VersionName:
type: string
pattern: '^$|^v\d+$'
Expand Down
8 changes: 7 additions & 1 deletion server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def get_project_collaborators(id):
project_role=project_role,
workspace_role=workspace_role,
role=ProjectPermissions.get_user_project_role(project, user),
name=user.profile.name(),
)
)

Expand Down Expand Up @@ -360,7 +361,12 @@ def create_project_version(id):
finally:
# remove artifacts
upload.clear()
return ProjectSchema().dump(project), 201

result = ProjectSchemaV2().dump(project)
result["files"] = ProjectFileSchema(
only=("path", "mtime", "size", "checksum"), many=True
).dump(project.files)
return result, 201


@auth_required
Expand Down
1 change: 1 addition & 0 deletions server/mergin/sync/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ class ProjectMemberSchema(Schema):
project_role = fields.Enum(enum=ProjectRole, by_value=True)
workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True)
role = fields.Enum(enum=ProjectRole, by_value=True)
name = fields.String()


class UploadChunkSchema(Schema):
Expand Down
6 changes: 5 additions & 1 deletion server/mergin/sync/storages/disk.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def _project_dir(self):
)
return project_dir

def initialize(self, template_project=None):
def initialize(self, template_project=None, excluded_files=None):
if os.path.exists(self.project_dir):
raise InitializationError(
"Project directory already exists: {}".format(self.project_dir)
Expand All @@ -193,8 +193,12 @@ def initialize(self, template_project=None):
if ws.disk_usage() + template_project.disk_usage > ws.storage:
self.delete()
raise InitializationError("Disk quota reached")
if excluded_files is None:
excluded_files = []

for file in template_project.files:
if os.path.basename(file.path) in excluded_files:
continue
src = os.path.join(template_project.storage.project_dir, file.location)
dest = os.path.join(
self.project_dir,
Expand Down
15 changes: 13 additions & 2 deletions server/mergin/tests/test_project_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1728,16 +1728,21 @@ def test_clone_project(client, data, username, expected):
assert resp.json["code"] == "StorageLimitHit"
assert resp.json["detail"] == "You have reached a data limit (StorageLimitHit)"
if expected == 200:
excluded_filenames = current_app.config.get("EXCLUDED_CLONE_FILENAMES")

proj = data.get("project", test_project).strip()
template = Project.query.filter_by(
name=test_project, workspace_id=test_workspace_id
).first()
project = Project.query.filter_by(
name=proj, workspace_id=test_workspace_id
).first()
template_files_filtered = [
f for f in template.files if f.path not in excluded_filenames
]
assert not any(
x.checksum != y.checksum and x.path != y.path
for x, y in zip(project.files, template.files)
for x, y in zip(project.files, template_files_filtered)
)
assert os.path.exists(
os.path.join(project.storage.project_dir, project.files[0].location)
Expand All @@ -1755,6 +1760,12 @@ def test_clone_project(client, data, username, expected):
item for item in changes if item.change == PushChangeType.UPDATE.value
]
assert pv.device_id == json_headers["X-Device-Id"]

assert not any(f.path == excluded_filenames[0] for f in project.files)
assert not os.path.exists(
os.path.join(project.storage.project_dir, excluded_filenames[0])
)
assert len(project.files) == len(template.files) - 1
# cleanup
shutil.rmtree(project.storage.project_dir)

Expand Down Expand Up @@ -2000,7 +2011,7 @@ def test_get_projects_by_uuids(client):
{"page": 1, "per_page": 5, "desc": False},
200,
"v1",
{"added": 12, "removed": 0, "updated": 0, "updated_diff": 0},
{"added": 13, "removed": 0, "updated": 0, "updated_diff": 0},
),
(
{"page": 2, "per_page": 3, "desc": True},
Expand Down
4 changes: 4 additions & 0 deletions server/mergin/tests/test_projects/test/qgis_cfg.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!DOCTYPE qgis_authentication>
<qgis_authentication salt="852cd01b5ba209804a5d068f5b28c448" hash="cab305f8ee1500ff912f9d68375b49e8" civ="d5472f8b9ad315bdd250e44a3419a2b8b3c9fd01956c002220520097bfbcb099">0737fe398eb9f26bd847fb9da2407646a2e8c89dc2f93eba5a059f19eedd8017e50c557d2c7d435a2701d881cdaf4fbbd3a892e4367053b5bfc348b556ae252314d9b06fc70a4f184362d064023c1ed6c4dd7ee14dce10ea91595e8548f7ba3d3eaca1d41063f50d1ccc12bfb90c059271254dca780e0d60e68bd234844fda81a0781977907485b397aa1263aef81863625eb439dc349fca0dbc641b4a606657f17e55d2c02fbc95388bf9f96977c65fcf7b723689d5fcdaf73190a5597425b3d33c858c2c4ef8c334b5f601c98db05557c8f690cdb9f73c725bf7ee420fffb6037cc9e80c7374a55ab55baf4aaa7f1c957fd40bb69b9fcb41ec42b063330bbddcd73f4de69e47772309167cb20ef4fe3250db96c29b71772edd18c7e73c501d569f4f8deda15fb0bcf6701d81902a6fc6c722db9e0d766d18a45297232224738c07a1a4f8fe490954efcae05fc1e43eac4eb5efc8b9008dcff4cf3688db4b7e268c9adf75d88d4d1d3648232c6fc2ac98b49b3bbb19368ce460b4a7a9828558d473eb0f1ba34c09f1ba9ce0170ac6d6c656176760e4012c56daef3f5f05320d5f84260d2e6b5a0b15620c33802d1c8c2f28084eef63b32f8130edd4789972b25960e12eb79351d11316f78aea3a941b9e7f1f4042f708d873ac7807ce0652819b2e2f77f9aac1c50cf72d2341118c41419f5f4c31474d8dbe56558dab9cb4b8e4fe8df9c8b4a057d9c6fe6b098b78e150aadd2a45cf1ea15e02f8f1f8b1b46d1a5513c26a63ea08788675feaa912e884ceee57adc120393c8a5bc42988f7b210195f6eff5de3e332d0d67321d05b907f836eb0f0f9e97388f89b699638804978639ad8b4c889f1a56952f949242679506cba5cc35538ea01b1621dd6a154f92b721b5247e294a5394df9c87765675b737dcb28346fc4032b68f87f46150aa4aa136378903036aff61fd41cf0cbcdd0865660f26d7f1d49f29ff5962adc209b9db71d12bf49bf67950496f18ca1de0a5cd7186e1bc0fcf826ffd1bb91ab36412c43730db5ff9ec57990fee27c5158446294bf0d8e61e12ee53e80b606b541c754ed45b2289079df8b647a8ca12fb1706e371523a581af50d333adfe5e84bcce2a60e84e24bdc1eb74610bc28b279b15c4f2020b045d2e4a7f846e488d74e761d98c05f105452235f602b3fe8beccf4b11d35ba6042dcc97f68090f40edbd6e8497434c193343cca98ebeabdd8620ec7eec642efda7cd45f0a9547ea821ac193eb1a8fb8c9c71d2e607b4651de5b8b613bc38aa4ba06bdb65a3d6b6e92546f1a4113e0bbce99aadbab3bbb07f31d6f90b3ff58b4494815e97a265c1c5e8a826bf14177427e03247395a18941753c0e580c42661a9c959ad57b93b97fb4adeca49927f3bec95eff361e95c324623f1c7c4d39e71250938d4189461cd6c1e978a5445f88eaf47670f23145cb7c8faf42ac83158743004fefb17a37a25edcf2425d530dd12ca52fdcfc399542cd288773c06931ce9aaac94df69dc6514fa3b1b8629dcfe725c0dcd77b5db967c5620dbc2444f4b78fb247e33a54ed2cbcaba3b92833b6d75b4900697da646f04da9a04d6353556b0ab70f8dd952eed9bd9cee1d53e760b292080862a74f625eb402662aadd94efaa6cce0727d3ccab5b6e112f25562effadfcf70307800e0d28976327576e99380facea2828ddb6a85addb4d4c0cfdb73cd848a9f707f8f978caf5de82756c80f42d53719987d5b4826397de8674d75dc1308dd3e96af37e9b3e42175dca1a5ff58a4aa4881a344113711a93340ee6515e5b9d03d1f4979531c84ec187b9303ea763b2641f530144cf52a81812349511219fc92bb038ec62d438c3beaf723</qgis_authentication>

<!-- HASH: c60949a4387b0efb717777df1cd9e3da459574109674fb0f483e59869e11fe1b -->
Loading
Loading