mirror of
https://gitlab.dit.htwk-leipzig.de/htwk-software/htwkalender.git
synced 2025-07-16 17:48:49 +02:00
Merge branch '16-room-calendar' into 'development'
Resolve "room calendar" See merge request htwk-software/htwkalender!86
This commit is contained in:
@ -21,8 +21,9 @@ services:
|
|||||||
context: ./services
|
context: ./services
|
||||||
target: dev # prod
|
target: dev # prod
|
||||||
command: "--http=0.0.0.0:8090 --dir=/htwkalender-data-manager/data/pb_data"
|
command: "--http=0.0.0.0:8090 --dir=/htwkalender-data-manager/data/pb_data"
|
||||||
#ports:
|
ports:
|
||||||
# - "8090:8090"
|
- "8090:8090"
|
||||||
|
- "50051:50051"
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/htwkalender-data-manager/data # for production with volume
|
- pb_data:/htwkalender-data-manager/data # for production with volume
|
||||||
# - ./data-manager:/htwkalender/data # for development with bind mount from project directory
|
# - ./data-manager:/htwkalender/data # for development with bind mount from project directory
|
||||||
|
25
frontend/src/helpers/url.ts
Normal file
25
frontend/src/helpers/url.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
|
||||||
|
//Copyright (C) 2024 HTWKalender support@htwkalender.de
|
||||||
|
|
||||||
|
//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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import { inject } from "vue";
|
||||||
|
|
||||||
|
const domain = import.meta.env.SSR
|
||||||
|
? inject<string>("domain")!
|
||||||
|
: window.location.hostname;
|
||||||
|
|
||||||
|
export function getLink(path: string, selectedRoom: string) {
|
||||||
|
return "https://" + domain + path + selectedRoom;
|
||||||
|
}
|
@ -27,7 +27,8 @@
|
|||||||
"dropDownSelect": "Bitte wähle einen Raum aus",
|
"dropDownSelect": "Bitte wähle einen Raum aus",
|
||||||
"noRoomsAvailable": "Keine Räume verfügbar",
|
"noRoomsAvailable": "Keine Räume verfügbar",
|
||||||
"available": "verfügbar",
|
"available": "verfügbar",
|
||||||
"occupied": "belegt"
|
"occupied": "belegt",
|
||||||
|
"roomIcal": "iCal für "
|
||||||
},
|
},
|
||||||
"freeRooms": {
|
"freeRooms": {
|
||||||
"freeRooms": "Freie Räume",
|
"freeRooms": "Freie Räume",
|
||||||
|
@ -27,7 +27,8 @@
|
|||||||
"dropDownSelect": "please select a room",
|
"dropDownSelect": "please select a room",
|
||||||
"noRoomsAvailable": "no rooms listed",
|
"noRoomsAvailable": "no rooms listed",
|
||||||
"available": "available",
|
"available": "available",
|
||||||
"occupied": "occupied"
|
"occupied": "occupied",
|
||||||
|
"roomIcal": "iCal for "
|
||||||
},
|
},
|
||||||
"freeRooms": {
|
"freeRooms": {
|
||||||
"freeRooms": "free rooms",
|
"freeRooms": "free rooms",
|
||||||
|
@ -19,19 +19,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import tokenStore from "@/store/tokenStore.ts";
|
import tokenStore from "@/store/tokenStore.ts";
|
||||||
import { useToast } from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
import { computed, inject, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
import { router } from "@/main";
|
import { router } from "@/main";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { getLink } from "@/helpers/url.ts";
|
||||||
|
|
||||||
const { t } = useI18n({ useScope: "global" });
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const domain = import.meta.env.SSR
|
|
||||||
? inject<string>("domain")!
|
|
||||||
: window.location.hostname;
|
|
||||||
|
|
||||||
const getLink = () =>
|
|
||||||
"https://" + domain + "/api/feed?token=" + tokenStore().token;
|
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
toast.add({
|
toast.add({
|
||||||
@ -42,6 +37,15 @@ const show = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const failedClipboard = () => {
|
||||||
|
toast.add({
|
||||||
|
severity: "error",
|
||||||
|
summary: t("calendarLink.copyToastError"),
|
||||||
|
detail: t("calendarLink.copyToastErrorDetail"),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
rerouteIfTokenIsEmpty();
|
rerouteIfTokenIsEmpty();
|
||||||
});
|
});
|
||||||
@ -54,27 +58,20 @@ function rerouteIfTokenIsEmpty() {
|
|||||||
|
|
||||||
function copyToClipboard() {
|
function copyToClipboard() {
|
||||||
// Copy the text inside the text field
|
// Copy the text inside the text field
|
||||||
navigator.clipboard.writeText(getLink()).then(show, () => {
|
navigator.clipboard.writeText(getLink("/api/feed?token=", tokenStore().token)).then(() => show(),() => failedClipboard());
|
||||||
toast.add({
|
|
||||||
severity: "error",
|
|
||||||
summary: t("calendarLink.copyToastError"),
|
|
||||||
detail: t("calendarLink.copyToastErrorDetail"),
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const forwardToGoogle = () => {
|
const forwardToGoogle = () => {
|
||||||
window.open(
|
window.open(
|
||||||
"https://calendar.google.com/calendar/u/0/r?cid=" +
|
"https://calendar.google.com/calendar/u/0/r?cid=" +
|
||||||
encodeURI(getLink().replace("https://", "http://")),
|
encodeURI(getLink("/api/feed?token=", tokenStore().token).replace("https://", "http://")),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const forwardToMicrosoft = () => {
|
const forwardToMicrosoft = () => {
|
||||||
window.open(
|
window.open(
|
||||||
"https://outlook.live.com/owa?path=/calendar/action/compose&rru=addsubscription&name=HTWK%20Kalender&url=" +
|
"https://outlook.live.com/owa?path=/calendar/action/compose&rru=addsubscription&name=HTWK%20Kalender&url=" +
|
||||||
encodeURI(getLink()),
|
encodeURI(getLink("/api/feed?token=", tokenStore().token)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,7 +114,7 @@ const actions = computed(() => [
|
|||||||
<div class="flex flex-column mt-8">
|
<div class="flex flex-column mt-8">
|
||||||
<div class="flex align-items-center justify-content-center m-2">
|
<div class="flex align-items-center justify-content-center m-2">
|
||||||
<h2 class="text-base md:text-2xl">
|
<h2 class="text-base md:text-2xl">
|
||||||
{{ getLink() }}
|
{{ getLink("/api/feed?token=", tokenStore().token) }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex align-items-center justify-content-center m-2">
|
<div class="flex align-items-center justify-content-center m-2">
|
||||||
|
@ -30,6 +30,18 @@ defineProps<{
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
upperButton?: {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
lowerButton?: {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@ -98,6 +110,23 @@ const hasContent = computed(() => {
|
|||||||
@click="button.onClick()"
|
@click="button.onClick()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="lowerButton"
|
||||||
|
class="flex flex-wrap my-3 gap-2 align-items-center justify-content-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:disabled="lowerButton.disabled"
|
||||||
|
class="col-12 md:col-4"
|
||||||
|
:icon="lowerButton.icon"
|
||||||
|
:label="lowerButton.label"
|
||||||
|
@click="lowerButton.onClick()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="button"
|
||||||
|
class="flex flex-wrap my-3 gap-2 align-items-center justify-content-end"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -118,7 +118,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
:rows="10"
|
:rows="10"
|
||||||
:global-filter-fields="['room']"
|
:global-filter-fields="['room']"
|
||||||
>
|
>
|
||||||
<Column field="room" sortable :header="$t('freeRooms.room')">
|
<Column field="room" :sortable="true" :header="$t('freeRooms.room')">
|
||||||
<template #filter="{ filterModel, filterCallback }">
|
<template #filter="{ filterModel, filterCallback }">
|
||||||
<InputText
|
<InputText
|
||||||
v-model="filterModel.value"
|
v-model="filterModel.value"
|
||||||
|
@ -23,6 +23,29 @@ import DynamicPage from "@/view/DynamicPage.vue";
|
|||||||
import RoomOccupation from "@/components/RoomOccupation.vue";
|
import RoomOccupation from "@/components/RoomOccupation.vue";
|
||||||
import { computedAsync } from "@vueuse/core";
|
import { computedAsync } from "@vueuse/core";
|
||||||
import { router } from "@/main";
|
import { router } from "@/main";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useToast } from "primevue/usetoast";
|
||||||
|
import { getLink } from "@/helpers/url.ts";
|
||||||
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
toast.add({
|
||||||
|
severity: "info",
|
||||||
|
summary: t("calendarLink.copyToastSummary"),
|
||||||
|
detail: t("calendarLink.copyToastNotification"),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const failedClipboard = () => {
|
||||||
|
toast.add({
|
||||||
|
severity: "error",
|
||||||
|
summary: t("calendarLink.copyToastError"),
|
||||||
|
detail: t("calendarLink.copyToastErrorDetail"),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
type Room = {
|
type Room = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -80,6 +103,21 @@ watch(selectedRoom, (newRoom: Room) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const button = computed(() => {
|
||||||
|
return {
|
||||||
|
label: t("roomFinderPage.roomIcal") + selectedRoom.value.name,
|
||||||
|
icon: "pi pi-clone",
|
||||||
|
disabled: selectedRoom.value.name === "",
|
||||||
|
onClick: () => {
|
||||||
|
// Copy iCal link to clipboard
|
||||||
|
// localhost/api/feed/room?id=selectedRoom.value.name
|
||||||
|
navigator.clipboard.writeText(getLink("/api/feed/room?id=", selectedRoom.value.name)).then(() => show, () => failedClipboard)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -88,6 +126,7 @@ watch(selectedRoom, (newRoom: Room) => {
|
|||||||
:headline="$t('roomFinderPage.headline')"
|
:headline="$t('roomFinderPage.headline')"
|
||||||
:sub-title="$t('roomFinderPage.detail')"
|
:sub-title="$t('roomFinderPage.detail')"
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
|
:lower-button="button"
|
||||||
>
|
>
|
||||||
<template #selection>
|
<template #selection>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@ -104,5 +143,10 @@ watch(selectedRoom, (newRoom: Room) => {
|
|||||||
<template #content>
|
<template #content>
|
||||||
<RoomOccupation :room="selectedRoom.name" />
|
<RoomOccupation :room="selectedRoom.name" />
|
||||||
</template>
|
</template>
|
||||||
|
<Button
|
||||||
|
class="col-12 md:col-4 mt-3"
|
||||||
|
:label="$t('roomFinderPage.reset')"
|
||||||
|
@click="selectedRoom.name = ''"
|
||||||
|
/>
|
||||||
</DynamicPage>
|
</DynamicPage>
|
||||||
</template>
|
</template>
|
||||||
|
@ -193,6 +193,24 @@ http {
|
|||||||
ssl_certificate cal.htwk-leipzig.de.pem;
|
ssl_certificate cal.htwk-leipzig.de.pem;
|
||||||
ssl_certificate_key cal.htwk-leipzig.de.key.pem;
|
ssl_certificate_key cal.htwk-leipzig.de.key.pem;
|
||||||
|
|
||||||
|
location /api/feed/room {
|
||||||
|
proxy_pass http://htwkalender-ical:8091;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
proxy_connect_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
send_timeout 600s;
|
||||||
|
proxy_cache_bypass 0;
|
||||||
|
proxy_no_cache 0;
|
||||||
|
proxy_cache mcache; # mcache=RAM
|
||||||
|
proxy_cache_valid 200 301 302 10m;
|
||||||
|
proxy_cache_valid 403 404 5m;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
proxy_cache_use_stale timeout updating;
|
||||||
|
add_header X-Proxy-Cache $upstream_cache_status;
|
||||||
|
limit_req zone=modules burst=5 nodelay;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/feed {
|
location /api/feed {
|
||||||
limit_req zone=createFeed nodelay;
|
limit_req zone=createFeed nodelay;
|
||||||
limit_req zone=feed burst=10 nodelay;
|
limit_req zone=feed burst=10 nodelay;
|
||||||
|
@ -145,6 +145,24 @@ http {
|
|||||||
ssl_certificate dev_htwkalender_de.pem;
|
ssl_certificate dev_htwkalender_de.pem;
|
||||||
ssl_certificate_key dev_htwkalender_de.key.pem;
|
ssl_certificate_key dev_htwkalender_de.key.pem;
|
||||||
|
|
||||||
|
location /api/feed/room {
|
||||||
|
proxy_pass http://htwkalender-ical:8091;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
proxy_connect_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
send_timeout 600s;
|
||||||
|
proxy_cache_bypass 0;
|
||||||
|
proxy_no_cache 0;
|
||||||
|
proxy_cache mcache; # mcache=RAM
|
||||||
|
proxy_cache_valid 200 301 302 10m;
|
||||||
|
proxy_cache_valid 403 404 5m;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
proxy_cache_use_stale timeout updating;
|
||||||
|
add_header X-Proxy-Cache $upstream_cache_status;
|
||||||
|
limit_req zone=modules burst=5 nodelay;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/feed {
|
location /api/feed {
|
||||||
limit_req zone=createFeed nodelay;
|
limit_req zone=createFeed nodelay;
|
||||||
limit_req zone=feed burst=10 nodelay;
|
limit_req zone=feed burst=10 nodelay;
|
||||||
|
225
services/common/genproto/modules/room.pb.go
Normal file
225
services/common/genproto/modules/room.pb.go
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.28.1
|
||||||
|
// protoc v3.21.12
|
||||||
|
// source: room.proto
|
||||||
|
|
||||||
|
package modules
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetRoomResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Room string `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"`
|
||||||
|
Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomResponse) Reset() {
|
||||||
|
*x = GetRoomResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_room_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetRoomResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetRoomResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_room_proto_msgTypes[0]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetRoomResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetRoomResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_room_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomResponse) GetRoom() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Room
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomResponse) GetEvents() []*Event {
|
||||||
|
if x != nil {
|
||||||
|
return x.Events
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetRoomRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Room string `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomRequest) Reset() {
|
||||||
|
*x = GetRoomRequest{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_room_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetRoomRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetRoomRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_room_proto_msgTypes[1]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetRoomRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetRoomRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_room_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRoomRequest) GetRoom() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Room
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_room_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_room_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x0a, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0d, 0x6d, 0x6f,
|
||||||
|
0x64, 0x75, 0x6c, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x45, 0x0a, 0x0f, 0x47,
|
||||||
|
0x65, 0x74, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12,
|
||||||
|
0x0a, 0x04, 0x72, 0x6f, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f,
|
||||||
|
0x6f, 0x6d, 0x12, 0x1e, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03,
|
||||||
|
0x28, 0x0b, 0x32, 0x06, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e,
|
||||||
|
0x74, 0x73, 0x22, 0x24, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x65, 0x71,
|
||||||
|
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6f, 0x6d, 0x32, 0x43, 0x0a, 0x0b, 0x52, 0x6f, 0x6f, 0x6d,
|
||||||
|
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x34, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x52, 0x6f,
|
||||||
|
0x6f, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x0f, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x6f,
|
||||||
|
0x6f, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x47, 0x65, 0x74, 0x52,
|
||||||
|
0x6f, 0x6f, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x1c, 0x5a,
|
||||||
|
0x1a, 0x68, 0x74, 0x77, 0x6b, 0x61, 0x6c, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x6d,
|
||||||
|
0x6d, 0x6f, 0x6e, 0x2f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f,
|
||||||
|
0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_room_proto_rawDescOnce sync.Once
|
||||||
|
file_room_proto_rawDescData = file_room_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_room_proto_rawDescGZIP() []byte {
|
||||||
|
file_room_proto_rawDescOnce.Do(func() {
|
||||||
|
file_room_proto_rawDescData = protoimpl.X.CompressGZIP(file_room_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_room_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_room_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||||
|
var file_room_proto_goTypes = []interface{}{
|
||||||
|
(*GetRoomResponse)(nil), // 0: GetRoomResponse
|
||||||
|
(*GetRoomRequest)(nil), // 1: GetRoomRequest
|
||||||
|
(*Event)(nil), // 2: Event
|
||||||
|
}
|
||||||
|
var file_room_proto_depIdxs = []int32{
|
||||||
|
2, // 0: GetRoomResponse.events:type_name -> Event
|
||||||
|
1, // 1: RoomService.GetRoomEvents:input_type -> GetRoomRequest
|
||||||
|
0, // 2: RoomService.GetRoomEvents:output_type -> GetRoomResponse
|
||||||
|
2, // [2:3] is the sub-list for method output_type
|
||||||
|
1, // [1:2] is the sub-list for method input_type
|
||||||
|
1, // [1:1] is the sub-list for extension type_name
|
||||||
|
1, // [1:1] is the sub-list for extension extendee
|
||||||
|
0, // [0:1] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_room_proto_init() }
|
||||||
|
func file_room_proto_init() {
|
||||||
|
if File_room_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_modules_proto_init()
|
||||||
|
if !protoimpl.UnsafeEnabled {
|
||||||
|
file_room_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*GetRoomResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_room_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*GetRoomRequest); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_room_proto_rawDesc,
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 2,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_room_proto_goTypes,
|
||||||
|
DependencyIndexes: file_room_proto_depIdxs,
|
||||||
|
MessageInfos: file_room_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_room_proto = out.File
|
||||||
|
file_room_proto_rawDesc = nil
|
||||||
|
file_room_proto_goTypes = nil
|
||||||
|
file_room_proto_depIdxs = nil
|
||||||
|
}
|
105
services/common/genproto/modules/room_grpc.pb.go
Normal file
105
services/common/genproto/modules/room_grpc.pb.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.2.0
|
||||||
|
// - protoc v3.21.12
|
||||||
|
// source: room.proto
|
||||||
|
|
||||||
|
package modules
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.32.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion7
|
||||||
|
|
||||||
|
// RoomServiceClient is the client API for RoomService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type RoomServiceClient interface {
|
||||||
|
GetRoomEvents(ctx context.Context, in *GetRoomRequest, opts ...grpc.CallOption) (*GetRoomResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoomServiceClient(cc grpc.ClientConnInterface) RoomServiceClient {
|
||||||
|
return &roomServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *roomServiceClient) GetRoomEvents(ctx context.Context, in *GetRoomRequest, opts ...grpc.CallOption) (*GetRoomResponse, error) {
|
||||||
|
out := new(GetRoomResponse)
|
||||||
|
err := c.cc.Invoke(ctx, "/RoomService/GetRoomEvents", in, out, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomServiceServer is the server API for RoomService service.
|
||||||
|
// All implementations must embed UnimplementedRoomServiceServer
|
||||||
|
// for forward compatibility
|
||||||
|
type RoomServiceServer interface {
|
||||||
|
GetRoomEvents(context.Context, *GetRoomRequest) (*GetRoomResponse, error)
|
||||||
|
mustEmbedUnimplementedRoomServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedRoomServiceServer must be embedded to have forward compatible implementations.
|
||||||
|
type UnimplementedRoomServiceServer struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedRoomServiceServer) GetRoomEvents(context.Context, *GetRoomRequest) (*GetRoomResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetRoomEvents not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedRoomServiceServer) mustEmbedUnimplementedRoomServiceServer() {}
|
||||||
|
|
||||||
|
// UnsafeRoomServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to RoomServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeRoomServiceServer interface {
|
||||||
|
mustEmbedUnimplementedRoomServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterRoomServiceServer(s grpc.ServiceRegistrar, srv RoomServiceServer) {
|
||||||
|
s.RegisterService(&RoomService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _RoomService_GetRoomEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetRoomRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(RoomServiceServer).GetRoomEvents(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: "/RoomService/GetRoomEvents",
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(RoomServiceServer).GetRoomEvents(ctx, req.(*GetRoomRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomService_ServiceDesc is the grpc.ServiceDesc for RoomService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var RoomService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "RoomService",
|
||||||
|
HandlerType: (*RoomServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "GetRoomEvents",
|
||||||
|
Handler: _RoomService_GetRoomEvents_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "room.proto",
|
||||||
|
}
|
@ -94,7 +94,7 @@ func GetRoomScheduleForDay(app *pocketbase.PocketBase, room string, date string)
|
|||||||
return events, nil
|
return events, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRoomSchedule(app *pocketbase.PocketBase, room string, from string, to string) ([]model.Event, error) {
|
func GetRoomScheduleInTimeSpan(app *pocketbase.PocketBase, room string, from string, to string) ([]model.Event, error) {
|
||||||
var events []model.Event
|
var events []model.Event
|
||||||
|
|
||||||
fromDate, err := time.Parse("2006-01-02", from)
|
fromDate, err := time.Parse("2006-01-02", from)
|
||||||
@ -118,3 +118,17 @@ func GetRoomSchedule(app *pocketbase.PocketBase, room string, from string, to st
|
|||||||
}
|
}
|
||||||
return events, nil
|
return events, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRoomSchedule(app *pocketbase.PocketBase, room string) ([]model.Event, error) {
|
||||||
|
var events []model.Event
|
||||||
|
|
||||||
|
// get all events from event records in the events collection
|
||||||
|
err := app.Dao().DB().Select("*").From("events").
|
||||||
|
Where(dbx.Like("Rooms", room).Escape("_", "_")).
|
||||||
|
GroupBy("Week", "Start", "End", "Rooms").
|
||||||
|
All(&events)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
36
services/data-manager/service/grpc/roomService.go
Normal file
36
services/data-manager/service/grpc/roomService.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
pb "htwkalender/common/genproto/modules"
|
||||||
|
"htwkalender/data-manager/service/db"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomServiceHandler struct {
|
||||||
|
app *pocketbase.PocketBase
|
||||||
|
pb.UnimplementedRoomServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RoomServiceHandler) GetRoomEvents(ctx context.Context, in *pb.GetRoomRequest) (*pb.GetRoomResponse, error) {
|
||||||
|
|
||||||
|
s.app.Logger().Info(
|
||||||
|
"Protobuf - GetRoomEvents",
|
||||||
|
"room", in.Room,
|
||||||
|
)
|
||||||
|
|
||||||
|
slog.Error("GetRoomEvents", "room", in.Room)
|
||||||
|
|
||||||
|
// get events from database by room
|
||||||
|
events, err := db.GetRoomSchedule(s.app, in.Room)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement your logic here to fetch events data based on the room
|
||||||
|
// Example response
|
||||||
|
return &pb.GetRoomResponse{
|
||||||
|
Events: eventsToProto(events),
|
||||||
|
}, nil
|
||||||
|
}
|
@ -39,6 +39,10 @@ func StartGRPCServer(app *pocketbase.PocketBase) {
|
|||||||
app: app,
|
app: app,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
pb.RegisterRoomServiceServer(s, &RoomServiceHandler{
|
||||||
|
app: app,
|
||||||
|
})
|
||||||
|
|
||||||
log.Printf("server listening at %v", lis.Addr())
|
log.Printf("server listening at %v", lis.Addr())
|
||||||
if err := s.Serve(lis); err != nil {
|
if err := s.Serve(lis); err != nil {
|
||||||
log.Fatalf("failed to serve: %v", err)
|
log.Fatalf("failed to serve: %v", err)
|
||||||
|
@ -48,7 +48,7 @@ func GetRoomSchedule(app *pocketbase.PocketBase, room string, from string, to st
|
|||||||
room = functions.MapRoom(room, false)
|
room = functions.MapRoom(room, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
roomSchedule, err := db.GetRoomSchedule(app, room, from, to)
|
roomSchedule, err := db.GetRoomScheduleInTimeSpan(app, room, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetEvents(modules []model.FeedModule, conn *grpc.ClientConn) (model.Events, error) {
|
func GetEventsByModules(modules []model.FeedModule, conn *grpc.ClientConn) (model.Events, error) {
|
||||||
c := pb.NewModuleServiceClient(conn)
|
c := pb.NewModuleServiceClient(conn)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -48,3 +48,21 @@ func GetEvents(modules []model.FeedModule, conn *grpc.ClientConn) (model.Events,
|
|||||||
|
|
||||||
return events, nil
|
return events, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetEventsByRoom(room string, conn *grpc.ClientConn) (model.Events, error) {
|
||||||
|
c := pb.NewRoomServiceClient(conn)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
r, err := c.GetRoomEvents(ctx, &pb.GetRoomRequest{Room: room})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not get room events: ", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := make(model.Events, 0)
|
||||||
|
for _, event := range r.GetEvents() {
|
||||||
|
events = append(events, protoToEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
@ -54,7 +54,7 @@ func Feed(app model.AppType, token string, userAgent string) (string, string, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all events for modules
|
// Get all events for modules
|
||||||
events, err = htwkalenderGrpc.GetEvents(feed.Modules, app.GrpcClient)
|
events, err = htwkalenderGrpc.GetEventsByModules(feed.Modules, app.GrpcClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@ -106,3 +106,23 @@ func CreateFeed(app model.AppType, modules []model.FeedCollection) (string, erro
|
|||||||
|
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FeedRoom(app model.AppType, room string) (string, string, error) {
|
||||||
|
|
||||||
|
// Get all events for room
|
||||||
|
events, err := htwkalenderGrpc.GetEventsByRoom(room, app.GrpcClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort events by start date
|
||||||
|
events.Sort()
|
||||||
|
|
||||||
|
// Generate one Hash for E-TAG from all events
|
||||||
|
etag := functions.HashString(events.String())
|
||||||
|
|
||||||
|
cal := GenerateIcalFeed(events, map[string]model.FeedCollection{}, "")
|
||||||
|
icalFeed := &model.FeedModel{Content: cal.Serialize(), ExpiresAt: model.JSONTime(time.Now().Add(expirationTime))}
|
||||||
|
|
||||||
|
return icalFeed.Content, etag, nil
|
||||||
|
}
|
||||||
|
@ -120,6 +120,27 @@ func AddFeedRoutes(app model.AppType) {
|
|||||||
return c.JSON(http.StatusOK, "token: "+token)
|
return c.JSON(http.StatusOK, "token: "+token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.Fiber.Get("/api/feed/room", func(c fiber.Ctx) error {
|
||||||
|
room := c.Query("id")
|
||||||
|
ifNoneMatch := c.Get("If-None-Match")
|
||||||
|
|
||||||
|
results, etag, err := ical.FeedRoom(app, room)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get feed", "error", err, "room", room)
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ifNoneMatch == etag && ifNoneMatch != "" {
|
||||||
|
return c.SendStatus(fiber.StatusNotModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header.Set("Content-type", "text/calendar")
|
||||||
|
c.Response().Header.Set("charset", "utf-8")
|
||||||
|
c.Response().Header.Set("Content-Disposition", "inline")
|
||||||
|
c.Response().Header.Set("filename", "calendar.ics")
|
||||||
|
return c.SendString(results)
|
||||||
|
})
|
||||||
|
|
||||||
app.Fiber.Head("/api/feed", func(c fiber.Ctx) error {
|
app.Fiber.Head("/api/feed", func(c fiber.Ctx) error {
|
||||||
return c.JSON(http.StatusOK, "")
|
return c.JSON(http.StatusOK, "")
|
||||||
})
|
})
|
||||||
|
19
services/protobuf/room.proto
Normal file
19
services/protobuf/room.proto
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
import "modules.proto";
|
||||||
|
|
||||||
|
option go_package = "htwkalender/common/modules";
|
||||||
|
|
||||||
|
service RoomService {
|
||||||
|
rpc GetRoomEvents(GetRoomRequest) returns (GetRoomResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomResponse {
|
||||||
|
string room = 1;
|
||||||
|
repeated Event events = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomRequest {
|
||||||
|
string room = 1;
|
||||||
|
}
|
||||||
|
|
Reference in New Issue
Block a user