From 4042f66efa05dc41cc55bb7ad03917e39a06685a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 30 Jul 2025 17:50:26 +0200 Subject: [PATCH] feat: show user export status in settings (#1200) --- .gitignore | 1 - frontend/src/i18n/lang/en.json | 4 +- frontend/src/services/dataExport.ts | 4 + .../src/views/user/DataExportDownload.vue | 9 ++- .../src/views/user/settings/DataExport.vue | 77 ++++++++++++++++++- pkg/db/fixtures/users.yml | 1 + pkg/models/label_test.go | 2 + pkg/models/project_users_test.go | 1 + pkg/models/task_collection_test.go | 1 + pkg/models/user_project_test.go | 1 + pkg/routes/api/v1/user_export.go | 43 +++++++++++ pkg/routes/routes.go | 1 + pkg/webtests/user_export_status_test.go | 41 ++++++++++ 13 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 pkg/webtests/user_export_status_test.go diff --git a/.gitignore b/.gitignore index 1aa518864..406362a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,3 @@ devenv.local.nix # AI Tools /.claude/ PLAN.md - diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index c04221b94..9f4e493b6 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -219,7 +219,9 @@ "descriptionPasswordRequired": "Please enter your password to proceed:", "request": "Request a copy of my Vikunja Data", "success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.", - "downloadTitle": "Download your exported Vikunja data" + "downloadTitle": "Download your exported Vikunja data", + "ready": "Your export is ready to download. You can download it until {0}.", + "requestNew": "Request another export" } }, "project": { diff --git a/frontend/src/services/dataExport.ts b/frontend/src/services/dataExport.ts index a131c6597..1ec5d6d94 100644 --- a/frontend/src/services/dataExport.ts +++ b/frontend/src/services/dataExport.ts @@ -7,6 +7,10 @@ export default class DataExportService extends AbstractService { request(password: string) { return this.post('/user/export/request', {password}) } + + status() { + return this.getM('/user/export') + } async download(password: string) { const clear = this.setLoading() diff --git a/frontend/src/views/user/DataExportDownload.vue b/frontend/src/views/user/DataExportDownload.vue index 24436143c..7db6f6bd2 100644 --- a/frontend/src/views/user/DataExportDownload.vue +++ b/frontend/src/views/user/DataExportDownload.vue @@ -34,11 +34,18 @@ {{ $t('misc.download') }} + + {{ $t('user.export.requestNew') }} + diff --git a/frontend/src/views/user/settings/DataExport.vue b/frontend/src/views/user/settings/DataExport.vue index a0d7ba487..f5ea3cdf1 100644 --- a/frontend/src/views/user/settings/DataExport.vue +++ b/frontend/src/views/user/settings/DataExport.vue @@ -1,5 +1,31 @@ - + + diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index 5399b48d8..b2cc573c3 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -6,6 +6,7 @@ issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 + export_file_id: 1 - id: 2 username: 'user2' diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index 080be4978..d948dd752 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -57,6 +57,7 @@ func TestLabel_ReadAll(t *testing.T) { OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, + ExportFileID: 1, } user2 := &user.User{ ID: 2, @@ -188,6 +189,7 @@ func TestLabel_ReadOne(t *testing.T) { OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, + ExportFileID: 1, } tests := []struct { name string diff --git a/pkg/models/project_users_test.go b/pkg/models/project_users_test.go index 7da80b7c4..37e1e8328 100644 --- a/pkg/models/project_users_test.go +++ b/pkg/models/project_users_test.go @@ -155,6 +155,7 @@ func TestProjectUser_ReadAll(t *testing.T) { OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, + ExportFileID: 1, }, Right: RightRead, } diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index c0488e571..90b092022 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -46,6 +46,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, + ExportFileID: 1, } user2 := &user.User{ ID: 2, diff --git a/pkg/models/user_project_test.go b/pkg/models/user_project_test.go index a9018a3b1..f56984947 100644 --- a/pkg/models/user_project_test.go +++ b/pkg/models/user_project_test.go @@ -35,6 +35,7 @@ func TestListUsersFromProject(t *testing.T) { OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, + ExportFileID: 1, } testuser2 := &user.User{ ID: 2, diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 7521a4b6e..a1fec1e91 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -18,6 +18,7 @@ package v1 import ( "net/http" + "time" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" @@ -139,3 +140,45 @@ func DownloadUserDataExport(c echo.Context) error { http.ServeContent(c.Response(), c.Request(), exportFile.Name, exportFile.Created, exportFile.File) return nil } + +type UserExportStatus struct { + ID int64 `json:"id"` + Size uint64 `json:"size"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` +} + +// GetUserExportStatus returns metadata about the current user export if it exists +// @Summary Get current user data export +// @tags user +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} v1.UserExportStatus +// @Router /user/export [get] +func GetUserExportStatus(c echo.Context) error { + s := db.NewSession() + defer s.Close() + + u, err := user.GetCurrentUserFromDB(s, c) + if err != nil { + return handler.HandleHTTPError(err) + } + + if u.ExportFileID == 0 { + return c.JSON(http.StatusOK, struct{}{}) + } + + exportFile := &files.File{ID: u.ExportFileID} + if err := exportFile.LoadFileMetaByID(); err != nil { + return handler.HandleHTTPError(err) + } + + status := UserExportStatus{ + ID: exportFile.ID, + Size: exportFile.Size, + Created: exportFile.Created, + Expires: exportFile.Created.Add(7 * 24 * time.Hour), + } + + return c.JSON(http.StatusOK, status) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 2347c9d5b..828e0ec5c 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -322,6 +322,7 @@ func registerAPIRoutes(a *echo.Group) { u.POST("/settings/general", apiv1.UpdateGeneralUserSettings) u.POST("/export/request", apiv1.RequestUserDataExport) u.POST("/export/download", apiv1.DownloadUserDataExport) + u.GET("/export", apiv1.GetUserExportStatus) u.GET("/timezones", apiv1.GetAvailableTimezones) u.PUT("/settings/token/caldav", apiv1.GenerateCaldavToken) u.GET("/settings/token/caldav", apiv1.GetCaldavTokens) diff --git a/pkg/webtests/user_export_status_test.go b/pkg/webtests/user_export_status_test.go new file mode 100644 index 000000000..6614aec1e --- /dev/null +++ b/pkg/webtests/user_export_status_test.go @@ -0,0 +1,41 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + apiv1 "code.vikunja.io/api/pkg/routes/api/v1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserExportStatus(t *testing.T) { + t.Run("no export", func(t *testing.T) { + rec, err := newTestRequestWithUser(t, http.MethodGet, apiv1.GetUserExportStatus, &testuser15, "", nil, nil) + require.NoError(t, err) + assert.Equal(t, "{}\n", rec.Body.String()) + }) + + t.Run("with export", func(t *testing.T) { + rec, err := newTestRequestWithUser(t, http.MethodGet, apiv1.GetUserExportStatus, &testuser1, "", nil, nil) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) +}