From b65f9f43fd05309a9d3732e204f7fdb44410f539 Mon Sep 17 00:00:00 2001 From: ymarcon Date: Fri, 18 Oct 2024 11:46:03 +0200 Subject: [PATCH] feat: campaign form --- .../workflows/publish-ui-container-dev.yml | 2 +- .github/workflows/publish-ui-container.yml | 2 +- backend/api/models/campaigns.py | 12 +- backend/api/models/files.py | 10 + backend/api/views/files.py | 28 +- frontend/.eslintrc-auto-import.json | 3 +- frontend/Dockerfile | 2 - frontend/quasar.config.js | 1 - frontend/src/auto-imports.d.ts | 2 + frontend/src/boot/api.ts | 2 +- frontend/src/components/AppToolbar.vue | 36 +- frontend/src/components/CampaignView.vue | 17 +- frontend/src/components/SimpleDialog.vue | 2 +- .../src/components/admin/CampaignForm.vue | 851 +++++++++++++++++- frontend/src/i18n/en/index.ts | 30 + frontend/src/models.ts | 21 +- frontend/src/pages/AdminPage.vue | 147 +-- frontend/src/pages/HomePage.vue | 31 +- frontend/src/pages/ProfilePage.vue | 37 - frontend/src/router/routes.ts | 4 - frontend/src/stores/admin.ts | 43 + frontend/src/stores/map.ts | 34 +- frontend/src/utils/streams.ts | 28 - 23 files changed, 1127 insertions(+), 218 deletions(-) create mode 100644 backend/api/models/files.py delete mode 100644 frontend/src/pages/ProfilePage.vue create mode 100644 frontend/src/stores/admin.ts delete mode 100644 frontend/src/utils/streams.ts diff --git a/.github/workflows/publish-ui-container-dev.yml b/.github/workflows/publish-ui-container-dev.yml index 44abfdd..f1ffd6c 100644 --- a/.github/workflows/publish-ui-container-dev.yml +++ b/.github/workflows/publish-ui-container-dev.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - name: Build image - run: docker build frontend --build-arg API_URL=https://icebreaker-dev.epfl.ch --build-arg API_PATH=/api --build-arg CDN_PATH=icebreaker-dev --build-arg AUTH_CLIENT_ID=icebreaker-dev-ui --tag $IMAGE_NAME + run: docker build frontend --build-arg API_URL=https://icebreaker-dev.epfl.ch --build-arg API_PATH=/api --build-arg AUTH_CLIENT_ID=icebreaker-dev-ui --tag $IMAGE_NAME - name: Log into registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin diff --git a/.github/workflows/publish-ui-container.yml b/.github/workflows/publish-ui-container.yml index 5c9b23a..bd42598 100644 --- a/.github/workflows/publish-ui-container.yml +++ b/.github/workflows/publish-ui-container.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v4 - name: Build image - run: docker build frontend --build-arg API_URL=https://icebreaker.epfl.ch --build-arg API_PATH=/api --build-arg CDN_PATH=icebreaker --build-arg AUTH_CLIENT_ID=icebreaker-ui --tag $IMAGE_NAME + run: docker build frontend --build-arg API_URL=https://icebreaker.epfl.ch --build-arg API_PATH=/api --build-arg AUTH_CLIENT_ID=icebreaker-ui --tag $IMAGE_NAME - name: Log into registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin diff --git a/backend/api/models/campaigns.py b/backend/api/models/campaigns.py index b0bf93b..a12d9dc 100644 --- a/backend/api/models/campaigns.py +++ b/backend/api/models/campaigns.py @@ -1,5 +1,6 @@ from typing import List, Optional, Tuple from pydantic import BaseModel +from api.models.files import FileRef class Reference(BaseModel): citation: str @@ -23,12 +24,17 @@ class TrackColumns(BaseModel): timestamp: Optional[str] = None class Track(BaseModel): - file: str + file: FileRef columns: TrackColumns color: Optional[str] = None timestamp_format: Optional[str] +class Funding(BaseModel): + name: str + grant: Optional[str] = None + website: Optional[str] = None + class Campaign(BaseModel): id: Optional[int] = None name: str @@ -43,9 +49,9 @@ class Campaign(BaseModel): platform: Optional[str] = None start_location: Tuple[float, float] end_location: Optional[Tuple[float, float]] = None - images: Optional[List[str]] = None + images: Optional[List[FileRef]] = None track: Optional[Track] = None - fundings: Optional[List[str]] = None + fundings: Optional[List[Funding]] = None references: Optional[List[Reference]] = None instruments: Optional[List[Instrument]] = None diff --git a/backend/api/models/files.py b/backend/api/models/files.py new file mode 100644 index 0000000..2fc4de8 --- /dev/null +++ b/backend/api/models/files.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import BaseModel + +class FileRef(BaseModel): + name: str + path: str + size: int + alt_name: Optional[str] = None + alt_path: Optional[str] = None + alt_size: Optional[int] = None \ No newline at end of file diff --git a/backend/api/views/files.py b/backend/api/views/files.py index dfcbcd7..de2b11c 100644 --- a/backend/api/views/files.py +++ b/backend/api/views/files.py @@ -2,19 +2,15 @@ Handle / uploads """ import datetime - from fastapi.datastructures import UploadFile from fastapi.param_functions import File from api.services.s3 import s3_client - from fastapi import Depends, Security, Query, APIRouter, HTTPException from fastapi.responses import Response - from api.utils.file_size import size_checker - +from api.auth import require_admin, User from pydantic import BaseModel - -from api.utils.file_nodes import FileNode +from api.config import config class FilePath(BaseModel): @@ -26,7 +22,7 @@ class FilePath(BaseModel): @router.get("/{file_path:path}", status_code=200, - description="-- Download any assets from S3 --") + description="Download any assets from S3") async def get_file(file_path: str, download: bool = Query( False, alias="d", description="Download file instead of inline display")): @@ -44,7 +40,7 @@ async def get_file(file_path: str, @router.post("/tmp", status_code=200, - description="-- Upload any assets to S3 --", + description="Upload any assets to S3", dependencies=[Depends(size_checker)]) async def upload_temp_files( files: list[UploadFile] = File(description="multiple file upload")): @@ -53,21 +49,15 @@ async def upload_temp_files( folder_name = str(current_time.timestamp()).replace('.', '') folder_path = f"tmp/{folder_name}" children = [await s3_client.upload_file(file, s3_folder=folder_path) for file in files] - parent_path = s3_client.to_s3_path(folder_path) - return { - "name": folder_name, - "path": parent_path, - "is_file": False, - "children": children - } + return children @router.delete("/{file_path:path}", status_code=204, - description="-- Delete asset present in S3 --", + description="Delete asset present in S3", ) -async def delete_temp_files(file_path: str): +async def delete_file(file_path: str, user: User = Depends(require_admin)): # delete path if it contains /tmp/ - if "/tmp/" in file_path: + if file_path.startswith(config.S3_PATH_PREFIX): await s3_client.delete_file(file_path) - return + return Response(status_code=204) \ No newline at end of file diff --git a/frontend/.eslintrc-auto-import.json b/frontend/.eslintrc-auto-import.json index 918899e..56cf1b6 100644 --- a/frontend/.eslintrc-auto-import.json +++ b/frontend/.eslintrc-auto-import.json @@ -105,6 +105,7 @@ "useId": true, "useModel": true, "useTemplateRef": true, - "useAuthStore": true + "useAuthStore": true, + "useAdminStore": true } } diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1b700ab..fa1f609 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,8 +9,6 @@ ARG API_URL ENV API_URL $API_URL ARG API_PATH ENV API_PATH $API_PATH -ARG CDN_PATH -ENV CDN_PATH $CDN_PATH ARG AUTH_CLIENT_ID ENV AUTH_CLIENT_ID $AUTH_CLIENT_ID RUN npm run build diff --git a/frontend/quasar.config.js b/frontend/quasar.config.js index d45b1e8..709ba9c 100644 --- a/frontend/quasar.config.js +++ b/frontend/quasar.config.js @@ -66,7 +66,6 @@ module.exports = configure(function (ctx) { env: { API_URL: ctx.dev ? 'http://localhost:8000' : process.env.API_URL, API_PATH: ctx.dev ? '' : process.env.API_PATH, - CDN_PATH: ctx.dev ? 'icebreaker-local-dev' : process.env.CDN_PATH, AUTH_CLIENT_ID: ctx.dev ? 'local-ui' : process.env.AUTH_CLIENT_ID, CESIUM_ACCESS_TOKEN: ctx.dev ? '' : process.env.CESIUM_ACCESS_TOKEN, }, diff --git a/frontend/src/auto-imports.d.ts b/frontend/src/auto-imports.d.ts index b9c2854..7e68cb7 100644 --- a/frontend/src/auto-imports.d.ts +++ b/frontend/src/auto-imports.d.ts @@ -74,6 +74,7 @@ declare global { const toValue: typeof import('vue')['toValue'] const triggerRef: typeof import('vue')['triggerRef'] const unref: typeof import('vue')['unref'] + const useAdminStore: typeof import('./stores/admin')['useAdminStore'] const useAttrs: typeof import('vue')['useAttrs'] const useAuthStore: typeof import('./stores/auth')['useAuthStore'] const useCatalogStore: typeof import('./stores/catalog')['useCatalogStore'] @@ -176,6 +177,7 @@ declare module 'vue' { readonly toValue: UnwrapRef readonly triggerRef: UnwrapRef readonly unref: UnwrapRef + readonly useAdminStore: UnwrapRef readonly useAttrs: UnwrapRef readonly useAuthStore: UnwrapRef readonly useCssModule: UnwrapRef diff --git a/frontend/src/boot/api.ts b/frontend/src/boot/api.ts index 8de9ae4..86df025 100644 --- a/frontend/src/boot/api.ts +++ b/frontend/src/boot/api.ts @@ -14,7 +14,7 @@ declare module '@vue/runtime-core' { } } -const cdnUrl = `https://enacit4r-cdn.epfl.ch/${process.env.CDN_PATH}`; +const cdnUrl = 'https://enacit4r-cdn.epfl.ch'; const baseUrl = `${process.env.API_URL}${process.env.API_PATH}`; diff --git a/frontend/src/components/AppToolbar.vue b/frontend/src/components/AppToolbar.vue index 84e7917..fe59c50 100644 --- a/frontend/src/components/AppToolbar.vue +++ b/frontend/src/components/AppToolbar.vue @@ -23,13 +23,6 @@ :label="mapStore.showGlobe ? 'Show 2D' : 'Show 3D'" @click="toggleShowGlobe" > - - - - - - {{ $t('profile') }} - {{ $t('user.login') }} - - - - - {{ $t('user.logout') }} - - - - - + icon="settings" + :title="$t('administration')" + :to="'/admin'" + > @@ -86,11 +63,6 @@ {{ $t('introduction') }} - - - {{ $t('profile') }} - - diff --git a/frontend/src/components/CampaignView.vue b/frontend/src/components/CampaignView.vue index a8678ca..bd07333 100644 --- a/frontend/src/components/CampaignView.vue +++ b/frontend/src/components/CampaignView.vue @@ -75,7 +75,7 @@ - {{ campaign.track.file.split('/').pop() }} + {{ campaign.track.file.name }} @@ -131,9 +131,16 @@ - + - {{ funding }} + {{ funding.name }} + {{ funding.grant }} + + + + {{ truncateString(funding.website, 40) }} + + @@ -161,11 +168,11 @@ const tab = ref('info'); const slide = ref(1); const imageUrls = computed(() => { - return props.campaign.images ? props.campaign.images.map((image) => `${cdnUrl}/campaigns/${props.campaign.id}/${image}`) : []; + return props.campaign.images ? props.campaign.images.map((image) => `${cdnUrl}/${image.path}`) : []; }); const trackUrl = computed(() => { - return props.campaign.track ? `${cdnUrl}/campaigns/${props.campaign.id}/${props.campaign.track.file}` : ''; + return props.campaign.track ? `${cdnUrl}/${props.campaign.track.file.path}` : ''; }); function truncateString(str: string, num: number) { diff --git a/frontend/src/components/SimpleDialog.vue b/frontend/src/components/SimpleDialog.vue index 66d8df7..168800f 100644 --- a/frontend/src/components/SimpleDialog.vue +++ b/frontend/src/components/SimpleDialog.vue @@ -7,7 +7,7 @@ - +
diff --git a/frontend/src/components/admin/CampaignForm.vue b/frontend/src/components/admin/CampaignForm.vue index 2e8acf5..c5c9f38 100644 --- a/frontend/src/components/admin/CampaignForm.vue +++ b/frontend/src/components/admin/CampaignForm.vue @@ -1,6 +1,614 @@ @@ -13,15 +621,256 @@ export default defineComponent({ \ No newline at end of file diff --git a/frontend/src/i18n/en/index.ts b/frontend/src/i18n/en/index.ts index efbc451..4b75eab 100644 --- a/frontend/src/i18n/en/index.ts +++ b/frontend/src/i18n/en/index.ts @@ -28,4 +28,34 @@ export default { dates: 'Dates', location: 'Location', track: 'Track', + images: 'Images', + references: 'References', + upload_file_hint: 'Drag and drop a file here or click to upload a file.', + add_image_file: 'Add image file', + add_track_file: 'Track file (csv or tsv)', + columns: 'Columns', + track_columns_info: 'The track file must contain at least columns about latitude and longitude. A timestamp column is optional.', + latitude_col_name: 'Latitude column name', + longitude_col_name: 'Longitude column name', + timestamp_col_name: 'Timestamp column name', + timestamp_format: 'Timestamp format', + color: 'Color', + track_color_hint: 'The color of the track line.', + acronym: 'Acronym', + name: 'Name', + website: 'Website', + summary: 'Summary', + platform: 'Platform', + start_date: 'Start date', + end_date: 'End date', + start_location_lat: 'Start location latitude', + start_location_lon: 'Start location longitude', + end_location_lat: 'End location latitude', + end_location_lon: 'End location longitude', + grant_number: 'Grant number', + apply: 'Apply', + cancel: 'Cancel', + citation: 'Citation', + doi: 'DOI', + description: 'Description', }; diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 577fe4b..5225b21 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -1,3 +1,12 @@ +export interface FileRef { + name: string; + path: string; + size: number; + alt_name?: string; + alt_path?: string; + alt_size?: number; +} + export interface Reference { citation: string; doi: string; @@ -16,7 +25,7 @@ export interface Instrument { } export interface Track { - file: string; + file: FileRef; columns: { latitude: string; longitude: string; @@ -26,6 +35,12 @@ export interface Track { timestamp_format?: string; } +export interface Funding { + name: string; + grant?: string; + website?: string; +} + export interface Campaign { id: string; name: string; @@ -40,9 +55,9 @@ export interface Campaign { platform?: string; start_location: [number, number]; end_location?: [number, number]; - images?: string[]; + images?: FileRef[]; track?: Track; - fundings: string[]; + fundings: Funding[]; references: Reference[]; instruments: Instrument[]; } diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index ec9b2fd..fb2798b 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -3,7 +3,17 @@
{{ $t('administration') }}
- + + {{ $t(authStore.isAdmin ? 'user.welcome_admin' : 'user.welcome', { name: authStore.profile?.firstName }) }} + +
@@ -15,69 +25,74 @@
{{ $t('campaigns_info') }}
-
-
-
-
- +
+ +
+
+
+
+
+
+ +
+
- -
-
-
- - - {{ campaigns[selected].acronym }} - - - - - +
+
+ + + {{ campaigns[selected].acronym }} + + + + + +
+ +
- -
@@ -115,4 +130,14 @@ onMounted(() => { }); }) + +function onAdd() { + mapStore.addCampaign(); + selected.value = campaigns.value.length - 1; +} + +function onDelete(index: number) { + mapStore.deleteCampaign(index); + selected.value = index === 0 ? 0 : index - 1; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index b5ec6f6..51f188c 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -5,22 +5,21 @@
- - - - - - - + v-model="split" + :horizontal="$q.screen.lt.md" + style="height: 94vh" + > + + + + +
- -
- {{ $t('profile') }} - - {{ authStore.profile?.email }} -
- -
-
-
- {{ $t(authStore.isAdmin ? 'user.welcome_admin' : 'user.welcome', { name: authStore.profile?.firstName }) }} -
- -
-
- -
-
-
- - - \ No newline at end of file diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index 23e6397..336972e 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -10,10 +10,6 @@ const routes: RouteRecordRaw[] = [ path: '/admin', component: () => import('pages/AdminPage.vue'), }, - { - path: '/profile', - component: () => import('pages/ProfilePage.vue'), - }, ], }, diff --git a/frontend/src/stores/admin.ts b/frontend/src/stores/admin.ts new file mode 100644 index 0000000..76caaa1 --- /dev/null +++ b/frontend/src/stores/admin.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia'; +import { FileRef } from 'src/models'; +import { api } from 'src/boot/api'; +import { FileObject } from 'src/components/models'; + +const authStore = useAuthStore(); + +export const useAdminStore = defineStore('admin', () => { + + async function uploadTmpFile(file: FileObject): Promise { + const formData = new FormData(); + formData.append('files', file); + return api.post('/files/tmp', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }).then((res) => res.data && res.data.length ? res.data[0] : null); + } + + async function deleteFile(file: FileRef) { + if (!file.path) return; + if (!authStore.isAuthenticated) return Promise.reject('Not authenticated'); + return authStore.updateToken().then(() => { + const config = { + headers: { + Authorization: `Bearer ${authStore.accessToken}`, + }, + }; + if (file.alt_path) { + return Promise.all([ + api.delete(`/files/${file.path}`, config), + api.delete(`/files/${file.alt_path}`, config), + ]); + } + return api.delete(`/files/${file.path}`, config); + }); + } + + return { + uploadTmpFile, + deleteFile, + } +}); \ No newline at end of file diff --git a/frontend/src/stores/map.ts b/frontend/src/stores/map.ts index 59db545..6775fc5 100644 --- a/frontend/src/stores/map.ts +++ b/frontend/src/stores/map.ts @@ -24,7 +24,7 @@ export const useMapStore = defineStore('map', () => { function getTrackData(campaign: Campaign, callback: CsvParseCallback) { if (!campaign.track) return callback([]); - const trackUrl = `${cdnUrl}/campaigns/${campaign.id}/${campaign.track.file}`; + const trackUrl = `${cdnUrl}/${campaign.track.file.path}`; if (tracks.value && tracks.value[trackUrl]) { return callback(tracks.value[trackUrl]); } @@ -46,6 +46,36 @@ export const useMapStore = defineStore('map', () => { }); } + function addCampaign() { + let id = 1; + for (const campaign of campaigns.value) { + if (campaign.id && parseInt(campaign.id) >= id) { + id = parseInt(campaign.id) + 1; + } + } + campaigns.value.push({ + id: id + '', + acronym: `campaign-${id}`, + name: `Campaign ${id}`, + website: '', + objectives: '', + platform: '', + start_date: '', + end_date: '', + start_location: [0, 0], + end_location: undefined, + track: undefined, + images: [], + fundings: [], + references: [], + instruments: [], + } as Campaign); + } + + function deleteCampaign(index: number) { + campaigns.value.splice(index, 1); + } + return { tileLayer, selectedCampaign, @@ -54,5 +84,7 @@ export const useMapStore = defineStore('map', () => { showGlobe, loadCampaigns, getTrackData, + addCampaign, + deleteCampaign, }; }); \ No newline at end of file diff --git a/frontend/src/utils/streams.ts b/frontend/src/utils/streams.ts deleted file mode 100644 index 793e353..0000000 --- a/frontend/src/utils/streams.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * A TransformStream that limits the amount of data that can be written to it. - */ -export class LimitedTransformStream extends TransformStream { - constructor(limit = 10000) { - let bytesWritten = 0; - - super({ - transform(chunk, controller) { - // Track how much data has been written - const chunkLength = chunk.length || chunk.byteLength; - if (bytesWritten + chunkLength > limit) { - const remainingBytes = limit - bytesWritten; - if (remainingBytes > 0) { - // Write only the remaining allowed bytes - controller.enqueue(chunk.slice(0, remainingBytes)); - bytesWritten += remainingBytes; - } - // Ignore the rest of the stream since the limit is reached - } else { - // If under the limit, write the full chunk - controller.enqueue(chunk); - bytesWritten += chunkLength; - } - }, - }); - } -} \ No newline at end of file