Merge branch 'external-events' into 'main'

External events

See merge request framasoft/mobilizon!1229
This commit is contained in:
Thomas Citharel 2023-09-01 16:42:29 +00:00
commit 19f595c2d8
21 changed files with 209 additions and 10 deletions

View file

@ -41,7 +41,10 @@ config :mobilizon, :instance,
email_reply_to: "noreply@localhost"
config :mobilizon, :groups, enabled: true
config :mobilizon, :events, creation: true
config :mobilizon, :events,
creation: true,
external: true
config :mobilizon, :restrictions, only_admin_can_create_groups: false
config :mobilizon, :restrictions, only_groups_can_create_events: false

View file

@ -1,7 +1,13 @@
<template>
<div class="">
<external-participation-button
v-if="event && event.joinOptions === EventJoinOptions.EXTERNAL"
:event="event"
:current-actor="currentActor"
/>
<participation-section
v-if="event && anonymousParticipationConfig"
v-else-if="event && anonymousParticipationConfig"
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
@ -15,7 +21,10 @@
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>
<div class="flex flex-col gap-1 mt-1">
<p class="inline-flex gap-2 ml-auto">
<p
class="inline-flex gap-2 ml-auto"
v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
>
<TicketConfirmationOutline />
<router-link
class="participations-link"
@ -349,6 +358,7 @@ import { useMutation } from "@vue/apollo-composable";
import { useCreateReport } from "@/composition/apollo/report";
import { useDeleteEvent } from "@/composition/apollo/event";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import ExternalParticipationButton from "./ExternalParticipationButton.vue";
const ShareEventModal = defineAsyncComponent(
() => import("@/components/Event/ShareEventModal.vue")

View file

@ -0,0 +1,30 @@
<template>
<o-button
tag="a"
:href="
event.externalParticipationUrl
? encodeURI(`${event.externalParticipationUrl}?uuid=${event.uuid}`)
: '#'
"
rel="noopener ugc"
target="_blank"
:disabled="!event.externalParticipationUrl"
icon-right="OpenInNew"
>
{{ t("Go to booking") }}
</o-button>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { IEvent } from "../../types/event.model";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const props = defineProps<{
event: IEvent;
}>();
const event = computed(() => props.event);
</script>

View file

@ -72,6 +72,7 @@ export const CONFIG = gql`
features {
groups
eventCreation
eventExternal
antispam
}
restrictions {
@ -370,6 +371,7 @@ export const FEATURES = gql`
features {
groups
eventCreation
eventExternal
antispam
}
}

View file

@ -21,6 +21,7 @@ const FULL_EVENT_FRAGMENT = gql`
status
visibility
joinOptions
externalParticipationUrl
draft
language
category
@ -121,6 +122,7 @@ export const FETCH_EVENT_BASIC = gql`
id
uuid
joinOptions
externalParticipationUrl
participantStats {
going
notApproved
@ -199,6 +201,7 @@ export const CREATE_EVENT = gql`
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$externalParticipationUrl: String
$draft: Boolean
$tags: [String]
$picture: MediaInput
@ -220,6 +223,7 @@ export const CREATE_EVENT = gql`
status: $status
visibility: $visibility
joinOptions: $joinOptions
externalParticipationUrl: $externalParticipationUrl
draft: $draft
tags: $tags
picture: $picture
@ -247,6 +251,7 @@ export const EDIT_EVENT = gql`
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$externalParticipationUrl: String
$draft: Boolean
$tags: [String]
$picture: MediaInput
@ -269,6 +274,7 @@ export const EDIT_EVENT = gql`
status: $status
visibility: $visibility
joinOptions: $joinOptions
externalParticipationUrl: $externalParticipationUrl
draft: $draft
tags: $tags
picture: $picture

View file

@ -1605,5 +1605,10 @@
"Reported by an unknown actor": "Reported by an unknown actor",
"Reported at": "Reported at",
"Updated at": "Updated at",
"Suspend the profile?": "Suspend the profile?"
"Suspend the profile?": "Suspend the profile?",
"Go to booking": "Go to booking",
"External registration": "External registration",
"I want to manage the registration with an external provider": "I want to manage the registration with an external provider",
"External provider URL": "External provider URL",
"Members will also access private sections like discussions, resources and restricted posts.": "Members will also access private sections like discussions, resources and restricted posts."
}

View file

@ -1601,5 +1601,10 @@
"{username} was invited to {group}": "{username} a été invité à {group}",
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Go to booking": "Aller à la réservation",
"External registration": "Inscription externe",
"I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
"External provider URL": "URL du fournisseur externe",
"Members will also access private sections like discussions, resources and restricted posts.": "Les membres auront également accès aux section privées comme les discussions, les ressources et les billets restreints."
}

View file

@ -96,6 +96,7 @@ export interface IConfig {
timezones: string[];
features: {
eventCreation: boolean;
eventExternal: boolean;
groups: boolean;
antispam: boolean;
};

View file

@ -64,6 +64,7 @@ export enum EventJoinOptions {
FREE = "FREE",
RESTRICTED = "RESTRICTED",
INVITE = "INVITE",
EXTERNAL = "EXTERNAL",
}
export enum EventVisibilityJoinOptions {

View file

@ -43,6 +43,7 @@ interface IEventEditJSON {
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
externalParticipationUrl: string | null;
draft: boolean;
picture?: IMedia | { mediaId: string } | null;
attributedToId: string | null;
@ -72,6 +73,7 @@ export interface IEvent {
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
externalParticipationUrl: string | null;
draft: boolean;
picture: IMedia | null;
@ -132,6 +134,8 @@ export class EventModel implements IEvent {
joinOptions = EventJoinOptions.FREE;
externalParticipationUrl: string | null = null;
status = EventStatus.CONFIRMED;
draft = true;
@ -197,6 +201,7 @@ export class EventModel implements IEvent {
this.status = hash.status;
this.visibility = hash.visibility;
this.joinOptions = hash.joinOptions;
this.externalParticipationUrl = hash.externalParticipationUrl;
this.draft = hash.draft;
this.picture = hash.picture;
@ -248,6 +253,7 @@ export function toEditJSON(event: IEditableEvent): IEventEditJSON {
category: event.category,
visibility: event.visibility,
joinOptions: event.joinOptions,
externalParticipationUrl: event.externalParticipationUrl,
draft: event.draft,
tags: event.tags.map((t) => t.title),
onlineAddress: event.onlineAddress,

View file

@ -247,7 +247,28 @@
</div>-->
<o-field
v-if="anonymousParticipationConfig?.allowed"
:label="t('External registration')"
v-if="features?.eventExternal"
>
<o-switch v-model="externalParticipation">
{{
t("I want to manage the registration with an external provider")
}}
</o-switch>
</o-field>
<o-field v-if="externalParticipation" :label="t('URL')">
<o-input
icon="link"
type="url"
v-model="event.externalParticipationUrl"
:placeholder="t('External provider URL')"
required
/>
</o-field>
<o-field
v-if="anonymousParticipationConfig?.allowed && !externalParticipation"
:label="t('Anonymous participations')"
>
<o-switch v-model="eventOptions.anonymousParticipation">
@ -268,19 +289,22 @@
</o-switch>
</o-field>
<o-field :label="t('Participation approval')">
<o-field
:label="t('Participation approval')"
v-show="!externalParticipation"
>
<o-switch v-model="needsApproval">{{
t("I want to approve every participation request")
}}</o-switch>
</o-field>
<o-field :label="t('Number of places')">
<o-field :label="t('Number of places')" v-show="!externalParticipation">
<o-switch v-model="limitedPlaces">{{
t("Limited number of places")
}}</o-switch>
</o-field>
<div class="" v-if="limitedPlaces">
<div class="" v-if="limitedPlaces && !externalParticipation">
<o-field :label="t('Number of places')" label-for="number-of-places">
<o-input
type="number"
@ -1308,6 +1332,19 @@ const orderedCategories = computed(() => {
if (!eventCategories.value) return undefined;
return sortBy(eventCategories.value, ["label"]);
});
const externalParticipation = computed({
get() {
return event.value?.joinOptions === EventJoinOptions.EXTERNAL;
},
set(newValue) {
if (newValue === true) {
event.value.joinOptions = EventJoinOptions.EXTERNAL;
} else {
event.value.joinOptions = EventJoinOptions.FREE;
}
},
});
</script>
<style lang="scss">

View file

@ -78,6 +78,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
visibility: visibility,
join_options: Map.get(object, "joinMode", "free"),
local: is_local?(object["id"]),
external_participation_url: object["externalParticipationUrl"],
options: options,
metadata: metadata,
status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(),
@ -129,6 +130,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"mediaType" => "text/html",
"startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
"joinMode" => to_string(event.join_options),
"externalParticipationUrl" => event.external_participation_url,
"endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
"tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,

View file

@ -145,6 +145,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
features: %{
groups: Config.instance_group_feature_enabled?(),
event_creation: Config.instance_event_creation_enabled?(),
event_external: Config.instance_event_external_enabled?(),
antispam: AntiSpam.service().ready?()
},
restrictions: %{

View file

@ -254,6 +254,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id),
{:can_create_event, true} <- can_create_event(args),
{:event_external, true} <- edit_event_external_checker(args),
{:organizer_group_member, true} <-
{:organizer_group_member, is_organizer_group_member?(args)},
args_with_organizer <-
@ -281,6 +282,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
"Only groups can create events"
)}
{:event_external, false} ->
{:error,
dgettext(
"errors",
"Providing external registration is not allowed"
)}
{:organizer_group_member, false} ->
{:error,
dgettext(
@ -322,6 +330,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
end
end
@spec edit_event_external_checker(map()) :: {:event_external, boolean()}
defp edit_event_external_checker(args) do
if Config.instance_event_external_enabled?() do
{:event_external, true}
else
{:event_external,
Map.get(args, :join_options) != :external and
is_nil(Map.get(args, :external_participation_url))}
end
end
@doc """
Update an event
"""
@ -340,6 +359,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
args <- extract_timezone(args, user.id),
{:event_can_be_managed, true} <-
{:event_can_be_managed, can_event_be_updated_by?(event, actor)},
{:event_external, true} <- edit_event_external_checker(args),
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <-
API.Events.update_event(args, event) do
{:ok, event}
@ -351,6 +371,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
"This profile doesn't have permission to update an event on behalf of this group"
)}
{:event_external, false} ->
{:error,
dgettext(
"errors",
"Providing external registration is not allowed"
)}
{:error, :event_not_found} ->
{:error, dgettext("errors", "Event not found")}

View file

@ -314,6 +314,10 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
description: "Whether event creation is allowed on this instance"
)
field(:event_external, :boolean,
description: "Whether redirecting to external providers is authorized in event edition"
)
field(:antispam, :boolean, description: "Whether anti-spam is activated on this instance")
end

View file

@ -35,6 +35,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility")
field(:external_participation_url, :string, description: "External URL for participation")
field(:picture, :media,
description: "The event's picture",
@ -130,6 +131,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
value(:free, description: "Anyone can join and is automatically accepted")
value(:restricted, description: "Manual acceptation")
value(:invite, description: "Participants must be invited")
value(:external, description: "External registration")
end
@desc "The list of possible options for the event's status"
@ -398,6 +400,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The event's options to join"
)
arg(:external_participation_url, :string, description: "External URL for participation")
arg(:tags, list_of(:string),
default_value: [],
description: "The list of tags associated to the event"
@ -469,6 +473,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The event's options to join"
)
arg(:external_participation_url, :string, description: "External URL for participation")
arg(:tags, list_of(:string), description: "The list of tags associated to the event")
arg(:picture, :media_input,

View file

@ -357,6 +357,10 @@ defmodule Mobilizon.Config do
def instance_event_creation_enabled?,
do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation)
@spec instance_event_external_enabled? :: boolean
def instance_event_external_enabled?,
do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:external)
@spec instance_export_formats :: %{event_participants: list(String.t())}
def instance_export_formats do
%{

View file

@ -47,6 +47,7 @@ defmodule Mobilizon.Events.Event do
draft: boolean,
visibility: atom(),
join_options: atom(),
external_participation_url: String.t(),
publish_at: DateTime.t() | nil,
uuid: Ecto.UUID.t(),
online_address: String.t() | nil,
@ -81,6 +82,7 @@ defmodule Mobilizon.Events.Event do
:local,
:visibility,
:join_options,
:external_participation_url,
:publish_at,
:online_address,
:phone_address,
@ -105,6 +107,7 @@ defmodule Mobilizon.Events.Event do
field(:draft, :boolean, default: false)
field(:visibility, EventVisibility, default: :public)
field(:join_options, JoinOptions, default: :free)
field(:external_participation_url, :string)
field(:publish_at, :utc_datetime)
field(:uuid, Ecto.UUID, default: Ecto.UUID.generate())
field(:online_address, :string)

View file

@ -46,7 +46,8 @@ defmodule Mobilizon.Events do
defenum(JoinOptions, :join_options, [
:free,
:restricted,
:invite
:invite,
:external
])
defenum(EventStatus, :event_status, [

View file

@ -0,0 +1,33 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddExternalUrlForEvents do
use Ecto.Migration
alias Mobilizon.Events.JoinOptions
def up do
alter table(:events) do
add(:external_participation_url, :string)
end
reset_join_options_enum()
end
def down do
alter table(:events) do
remove(:external_participation_url, :string)
end
reset_join_options_enum()
end
defp reset_join_options_enum do
execute("ALTER TABLE events ALTER COLUMN join_options TYPE VARCHAR USING join_options::text")
execute("ALTER TABLE events ALTER COLUMN join_options DROP DEFAULT")
JoinOptions.drop_type()
JoinOptions.create_type()
execute(
"ALTER TABLE events ALTER COLUMN join_options TYPE join_options USING join_options::join_options"
)
execute("ALTER TABLE events ALTER COLUMN join_options SET DEFAULT 'free'::join_options")
end
end

View file

@ -1551,6 +1551,9 @@ type RootMutationType {
"The event's options to join"
joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The list of tags associated to the event"
tags: [String]
@ -1620,6 +1623,9 @@ type RootMutationType {
"The event's options to join"
joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The list of tags associated to the event"
tags: [String]
@ -2956,6 +2962,9 @@ type Event implements ActivityObject & Interactable & ActionLogObject {
"The event's visibility"
joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The event's picture"
picture: Media
@ -3144,6 +3153,9 @@ enum EventJoinOptions {
"Participants must be invited"
INVITE
"External registration"
EXTERNAL
}
type InstanceFeeds {