Skip to content
Extraits de code Groupes Projets
Valider 57c3364a rédigé par Brieuc Dubois's avatar Brieuc Dubois
Parcourir les fichiers

Dev

parent abbb02b5
Aucune branche associée trouvée
Aucune étiquette associée trouvée
1 requête de fusion!29Dev
Affichage de
avec 404 ajouts et 170 suppressions
......@@ -85,7 +85,7 @@ Sometimes, breaking changes are made in the database. In such cases, manual acti
#### Dependencies update
After an update, it may happen that the dependencies have changed. In such case, many "Missing Packages" errors would appear when running the frontend. In that case, running `npm run install` before `npm run dev` should fix the issue.
After an update, it may happen that the dependencies have changed. In such case, many "Missing Packages" errors would appear when running the frontend. In that case, running `npm install` before `npm run dev` should fix the issue.
#### API documentation
......
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:///languagelab.sqlite
[post_write_hooks]
hooks = black
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
Generic single-database configuration.
\ No newline at end of file
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
"""Update survey response with other languages
Revision ID: 37f4cc82f93e
Revises:
Create Date: 2024-12-22 18:42:42.049100
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "37f4cc82f93e"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("survey_response_info", sa.Column("other_language", sa.String))
op.drop_column("messages", "reply_to_message_id")
op.add_column("messages", sa.Column("reply_to_message_id", sa.Integer))
def downgrade() -> None:
pass
......@@ -127,10 +127,14 @@ class Message(Base):
user_id = Column(Integer, ForeignKey("users.id"))
session_id = Column(Integer, ForeignKey("sessions.id"))
created_at = Column(DateTime, default=datetime_aware)
reply_to_message_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_message_id = Column(
Integer, ForeignKey("messages.message_id"), nullable=True
)
feedbacks = relationship("MessageFeedback", backref="message")
replies = relationship("Message", backref="parent_message", remote_side=[id])
replies = relationship(
"Message", backref="parent_message", remote_side=[message_id]
)
def raw(self):
return [
......@@ -269,6 +273,7 @@ class SurveyResponseInfo(Base):
birthyear = Column(Integer)
gender = Column(String)
primary_language = Column(String)
other_language = Column(String)
education = Column(String)
......
......@@ -154,7 +154,7 @@ class Message(BaseModel):
user_id: int
session_id: int
created_at: NaiveDatetime
reply_to_message_id: int | None = None
reply_to_message_id: str | None = None
feedbacks: list[MessageFeedback]
class Config:
......@@ -184,7 +184,7 @@ class MessageMetadataCreate(BaseModel):
class MessageCreate(BaseModel):
message_id: str | None = None
content: str
reply_to_message_id: int | None = None
reply_to_message_id: str | None = None
metadata: list[MessageMetadataCreate]
class Config:
......@@ -307,6 +307,7 @@ class SurveyResponseInfoCreate(BaseModel):
birthyear: int
gender: str
primary_language: str
other_language: str
education: str
......@@ -316,6 +317,7 @@ class SurveyResponseInfo(BaseModel):
birthyear: int
gender: str
primary_language: str
other_language: str
education: str
......
......@@ -186,6 +186,8 @@
"Master": "Master's degree or above"
},
"example": "Example",
"otherLanguage": "Is there any other language you are relatively fluent in?",
"otherLanguageNote": "(If you are fluent in multiple other languages, beyond your target language and your first language, please select the one closest to your target language or that seems to help you the most in learning your target language.)",
"consent": {
"intro": "You are invited to participate in a scientific study to design, validate and apply vocabulary tests.",
"ok": "I agree to participate in the study as described above.",
......
......@@ -274,7 +274,9 @@
"Bachelor": "Bachelier",
"Master": "Master ou diplôme supérieur"
},
"example": "Exemple"
"example": "Exemple",
"otherLanguage": "Is there any other language you are relatively fluent in?",
"otherLanguageNote": "(If you are fluent in multiple other languages, beyond your target language and your first language, please select the one closest to your target language or that seems to help you the most in learning your target language.)"
},
"users": {
"nickname": "Nom",
......
......@@ -41,7 +41,7 @@ export async function createMessageAPI(
id: number,
content: string,
metadata: { message: string; date: number }[],
replyTo: number | null
replyTo: string | null
): Promise<any | null> {
const response = await fetch(`/api/sessions/${id}/messages`, {
method: 'POST',
......
......@@ -52,6 +52,7 @@ export async function sendSurveyResponseInfoAPI(
birthyear: number,
gender: string,
primary_language: string,
other_language: string,
education: string
) {
const response = await fetch(`/api/surveys/info/${survey_id}`, {
......@@ -62,6 +63,7 @@ export async function sendSurveyResponseInfoAPI(
birthyear,
gender,
primary_language,
other_language,
education
})
});
......
......@@ -16,7 +16,7 @@ export default class Message {
private _edited: boolean = false;
private _versions = writable([] as { content: string; date: Date }[]);
private _feedbacks = writable([] as Feedback[]);
private _replyTo: number;
private _replyTo: string;
public constructor(
id: number,
......@@ -25,7 +25,7 @@ export default class Message {
created_at: Date,
user: User,
session: Session,
replyTo: number
replyTo: string
) {
this._id = id;
this._message_id = message_id;
......@@ -77,7 +77,7 @@ export default class Message {
return `message-${this._message_id}`;
}
get replyTo(): number {
get replyTo(): string {
return this._replyTo;
}
......@@ -85,7 +85,7 @@ export default class Message {
if (this._replyTo == null) return undefined;
return get(this._session.messages).find(
(m) => m instanceof Message && m.id == this._replyTo
(m) => m instanceof Message && m.message_id == this._replyTo
) as Message | undefined;
}
......
......@@ -201,7 +201,7 @@ export default class Session {
sender: User,
content: string,
metadata: { message: string; date: number }[],
replyTo: number | null
replyTo: string | null
): Promise<Message | null> {
const json = await createMessageAPI(fetch, this.id, content, metadata, replyTo);
if (json == null || json.id == null || json.message_id == null) {
......
......@@ -37,7 +37,12 @@
if (message.length == 0) return;
try {
const m = await session.sendMessage(user, message, [...metadata], replyTo?.id || null);
const m = await session.sendMessage(
user,
message,
[...metadata],
replyTo?.message_id || null
);
if (m === null) {
toastAlert($t('chatbox.sendError'));
......
......@@ -42,13 +42,13 @@
let questionsRandomized = $derived(getSortedQuestions(currentGroup));
let currentQuestionId = $state(0);
let currentQuestion = $derived(questionsRandomized[currentQuestionId]);
let type = $derived(currentQuestion.question.split(':')[0]);
let value = $derived(currentQuestion.question.split(':').slice(1).join(':'));
let type = $derived(currentQuestion?.question.split(':')[0]);
let value = $derived(currentQuestion?.question.split(':').slice(1).join(':'));
let gaps = $derived(type === 'gap' ? gapParts(currentQuestion.question) : null);
let soundPlayer: HTMLAudioElement;
let displayQuestionOptions: string[] = $derived(
(() => {
let d = [...(currentQuestion.options ?? [])];
let d = [...(currentQuestion?.options ?? [])];
shuffle(d);
return d;
})()
......@@ -194,7 +194,7 @@
async function selectAnswer(selection: string, option: string) {
endSurveyAnswers[selection] = option;
subStep += 1;
if (subStep == 4) {
if (subStep == 5) {
await sendSurveyResponseInfoAPI(
fetch,
survey.id,
......@@ -202,6 +202,7 @@
endSurveyAnswers.birthYear,
endSurveyAnswers.gender,
endSurveyAnswers.primaryLanguage,
endSurveyAnswers.other_language,
endSurveyAnswers.education
);
step += 1;
......@@ -406,6 +407,23 @@
></Dropdown>
</div>
{:else if subStep === 3}
<div class="mx-auto mt-16 text-center px-4">
<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.otherLanguage')}</p>
<p class="mb-6 text-sm text-gray-600 text-center">{$t('surveys.otherLanguageNote')}</p>
<Dropdown
values={[
{ value: 'none', display: '/' },
...Object.entries(config.PRIMARY_LANGUAGE).map(([code, name]) => ({
value: code,
display: name
}))
]}
bind:option={selectedOption}
placeholder={$t('surveys.otherLanguage')}
funct={() => selectAnswer('other_language', selectedOption)}
></Dropdown>
</div>
{:else if subStep === 4}
<div class="mx-auto mt-16 text-center px-4">
<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.education.title')}</p>
<Dropdown
......@@ -426,7 +444,7 @@
{:else}
{(step += 1)}
{/if}
{:else if step == 4}
{:else if step === 4}
<div class="mx-auto mt-16 text-center">
<h1>{$t('surveys.complete')}</h1>
{#if finalScore !== null}
......
......@@ -8,7 +8,8 @@ export const load: ServerLoad = async ({ locals, url }) => {
redirect(303, '/login');
}
const user = JSON.parse(locals.user);
// const user = JSON.parse(locals.user);
const user = typeof locals.user === 'string' ? JSON.parse(locals.user) : locals.user;
if (user == null || user == undefined || user.type > 1) {
error(403, 'Forbidden');
}
......
import { redirect, type Actions } from '@sveltejs/kit';
export const actions: Actions = {
register: async ({ request, fetch }) => {
const formData = await request.formData();
const email = formData.get('email');
const nickname = formData.get('nickname');
const password = formData.get('password');
const is_tutor = formData.get('is_tutor') === 'true';
if (!email || !nickname || !password) {
return { message: 'Missing required fields' };
}
const registerResponse = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, nickname, password, is_tutor })
});
const registerResult = await registerResponse.json();
if (!registerResponse.ok) {
return { message: registerResult.detail || 'Unknown error occurred' };
}
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include'
});
if (!loginResponse.ok) {
return { message: 'Failed to log in after registration.' };
}
const user_id = registerResult;
const userResponse = await fetch(`/api/users/${user_id}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (!userResponse.ok) {
return { message: 'Failed to retrieve user data.' };
}
return redirect(303, '/tutor/register');
}
};
......@@ -2,48 +2,21 @@
import config from '$lib/config';
import { locale, t } from '$lib/services/i18n';
import { toastAlert, toastWarning } from '$lib/utils/toasts';
import { onMount } from 'svelte';
import Timeslots from '$lib/components/users/timeslots.svelte';
import User from '$lib/types/user';
import { getUsersAPI, patchUserAPI, getUserContactsAPI } from '$lib/api/users';
import { patchUserAPI } from '$lib/api/users';
import { Icon, Envelope, Key, UserCircle, Calendar, QuestionMarkCircle } from 'svelte-hero-icons';
import Typingtest from '$lib/components/tests/typingtest.svelte';
import { formatToUTCDate } from '$lib/utils/date';
import type { PageData } from './$types';
import { page } from '$app/stores';
let { data }: { data: PageData } = $props();
let user = data.user;
let current_step = $state(0);
let current_step = $state(user ? 3 : 1);
let message = $state('');
onMount(async () => {
if (user == null) {
current_step = 1;
return;
}
User.parseAll(await getUsersAPI(fetch));
if (!user.home_language || !user.target_language || !user.birthdate || !user.gender) {
current_step = 3;
return;
}
const contacts = User.parseAll(await getUserContactsAPI(fetch, user.id));
if (contacts.length == 0) {
current_step = 4;
return;
}
current_step = 5;
});
let nickname = '';
let email = '';
let password = '';
let confirmPassword = '';
let form = $page.form;
let ui_language: string = $locale;
let home_language: string;
......@@ -53,60 +26,18 @@
let timeslots = 0n;
async function onRegister() {
if (nickname == '' || email == '' || password == '' || confirmPassword == '') {
message = $t('register.error.emptyFields');
return;
}
if (password.length < 8) {
message = $t('register.error.passwordRules');
return;
}
if (password != confirmPassword) {
message = $t('register.error.differentPasswords');
return;
}
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
if (!emailRegex.test(email)) {
message = $t('register.error.emailRules');
return;
}
message = '';
const registerRes = await registerAPI(email, password, nickname, true);
if (registerRes !== 'OK') {
message = registerRes;
return;
}
const loginRes = await loginAPI(email, password);
if (loginRes !== 'OK') {
toastAlert('Failed to login: ' + loginRes);
document.location.href = '/login';
return;
}
document.location.href = '/tutor/register';
message = 'OK';
}
async function onData() {
const user_id = user.id;
if (!user_id) {
toastAlert('Failed to get current user ID');
if (!user) {
toastAlert('Failed to get current user');
return;
}
if (!ui_language || !home_language || !birthdate || !gender) {
if (!home_language || !birthdate || !gender) {
message = $t('register.error.emptyFields');
return;
}
const res = await patchUserAPI(fetch, user_id, {
const res = await patchUserAPI(fetch, user.id, {
ui_language,
home_language,
birthdate,
......@@ -122,12 +53,17 @@
}
async function onAvailabilities() {
if (!user) {
toastAlert('Failed to get current user');
return;
}
if (!calcom_link || !calcom_link.startsWith('https://cal.com/')) {
toastWarning($t('timeslots.calcomWarning'));
return;
}
const res = user.setAvailability(timeslots, calcom_link);
const res = await patchUserAPI(fetch, user.id, {
calcom_link
});
if (!res) return;
......@@ -139,6 +75,10 @@
}
</script>
{#if form?.message}
<div class="alert alert-error">{form.message}</div>
{/if}
<div class="header mx-auto my-5">
<ul class="steps text-xs">
<li class="step" class:step-primary={current_step >= 1}>
......@@ -251,71 +191,91 @@
</div>
</div>
<div class="form-control">
<button class="button mt-4" on:click={() => current_step++}>
<button class="button mt-4" onclick={() => current_step++}>
{$t('register.consentTutor.ok')}
</button>
</div>
{:else if current_step == 2}
<div class="space-y-2">
<label for="email" class="form-control">
<div class="label">
<span class="label-text">{$t('register.email')}</span>
<span class="label-text-alt">{$t('register.email.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={Envelope} class="w-4 mr-2 opacity-70" solid />
<input
type="text"
class="grow"
bind:value={email}
placeholder={$t('register.email.ph')}
/>
</div>
</label>
<label for="nickname" class="form-control">
<div class="label">
<span class="label-text">{$t('register.nickname')}</span>
<span class="label-text-alt">{$t('register.nickname.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={UserCircle} class="w-4 mr-2 opacity-70" solid />
<input
type="text"
class="grow"
bind:value={nickname}
placeholder={$t('register.nickname.ph')}
/>
</div>
</label>
<label for="password" class="form-control">
<div class="label">
<span class="label-text">{$t('register.password')}</span>
<span class="label-text-alt">{$t('register.password.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={Key} class="w-4 mr-2 opacity-70" solid />
<input
type="password"
class="grow"
bind:value={password}
placeholder={$t('register.password')}
/>
<div class="space-y-4">
<!-- Step 2: Tutor Registration Form -->
<form method="POST" action="?/register">
<label for="email" class="form-control">
<div class="label">
<span class="label-text">{$t('register.email')}</span>
<span class="label-text-alt">{$t('register.email.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={Envelope} class="w-4 mr-2 opacity-70" solid />
<input
type="email"
name="email"
class="grow"
placeholder={$t('register.email.ph')}
required
/>
</div>
</label>
<label for="nickname" class="form-control">
<div class="label">
<span class="label-text">{$t('register.nickname')}</span>
<span class="label-text-alt">{$t('register.nickname.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={UserCircle} class="w-4 mr-2 opacity-70" solid />
<input
type="text"
name="nickname"
class="grow"
placeholder={$t('register.nickname.ph')}
required
/>
</div>
</label>
<label for="password" class="form-control">
<div class="label">
<span class="label-text">{$t('register.password')}</span>
<span class="label-text-alt">{$t('register.password.note')}</span>
</div>
<div class="input flex items-center">
<Icon src={Key} class="w-4 mr-2 opacity-70" solid />
<input
type="password"
name="password"
class="grow"
placeholder={$t('register.password')}
required
/>
</div>
</label>
<label for="confirmPassword" class="form-control">
<div class="label">
<span class="label-text">{$t('register.confirmPassword')}</span>
</div>
<div class="input flex items-center">
<Icon src={Key} class="w-4 mr-2 opacity-70" solid />
<input
type="password"
name="confirmPassword"
class="grow"
placeholder={$t('register.confirmPassword')}
required
/>
</div>
</label>
<input type="hidden" name="is_tutor" value="true" />
<div class="form-control">
<button type="submit" class="button mt-4">
{$t('register.signup')}
</button>
</div>
</label>
<label for="confirmPassword" class="form-control">
<div class="input flex items-center">
<Icon src={Key} class="w-4 mr-2 opacity-70" solid />
<input
type="password"
class="grow"
bind:value={confirmPassword}
placeholder={$t('register.confirmPassword')}
/>
</form>
{#if $page.form?.message}
<div class="alert alert-error text-content text-base-100 py-2 mt-4">
{$page.form.message}
</div>
</label>
<div class="form-control">
<button class="button mt-2" on:click={onRegister}>{$t('register.signup')}</button>
</div>
{/if}
</div>
{:else if current_step == 3}
<div class="space-y-2">
......@@ -349,7 +309,7 @@
id="birthyear"
name="birthyear"
required
on:change={(e) => (birthdate = formatToUTCDate(new Date(e.target.value, 1, 1)))}
onchange={(e) => (birthdate = formatToUTCDate(new Date(e.target.value, 1, 1)))}
>
<option disabled selected value="">{$t('register.birthyear')}</option>
{#each Array.from({ length: 82 }, (_, i) => i + 1931).reverse() as year}
......@@ -369,7 +329,7 @@
id="male"
name="gender"
value="male"
on:change={() => (gender = 'male')}
onchange={() => (gender = 'male')}
/>
<label for="male" class="label-text cursor-pointer">
{$t('register.genders.male')}
......@@ -382,7 +342,7 @@
id="female"
name="gender"
value="female"
on:change={() => (gender = 'female')}
onchange={() => (gender = 'female')}
/>
<label for="female" class="label-text cursor-pointer">
{$t('register.genders.female')}
......@@ -395,7 +355,7 @@
id="other"
name="gender"
value="other"
on:change={() => (gender = 'other')}
onchange={() => (gender = 'other')}
/>
<label for="other" class="label-text cursor-pointer">
{$t('register.genders.other')}
......@@ -408,7 +368,7 @@
id="na"
name="gender"
value="na"
on:change={() => (gender = 'na')}
onchange={() => (gender = 'na')}
/>
<label for="na" class="label-text cursor-pointer">
{$t('register.genders.na')}
......@@ -416,11 +376,10 @@
</div>
</div>
<div class="form-control">
<button class="button mt-4" on:click={onData}>{$t('button.submit')}</button>
<button class="button mt-4" onclick={onData}>{$t('button.submit')}</button>
</div>
</div>
{:else if current_step == 4}
<!--{#if get(user)}-->
<h2 class="my-4 text-xl">{$t('timeslots.setAvailabilities')}</h2>
<Timeslots bind:timeslots />
<div class="form-control mt-4">
......@@ -452,28 +411,28 @@
</div>
</div>
<div class="form-control">
<button class="button mt-4" on:click={onAvailabilities}>{$t('button.submit')}</button>
<button class="button mt-4" onclick={onAvailabilities}>{$t('button.submit')}</button>
</div>
{:else if current_step == 5}
<div class="text-center">
<p class="text-center">
{@html $t('register.continue')}
</p>
<button class="button mt-4 w-full" on:click={() => (current_step = 6)}>
<button class="button mt-4 w-full" onclick={() => (current_step = 6)}>
{$t('register.continueButton')}
</button>
<button class="button mt-4 w-full" on:click={() => (document.location.href = '/')}>
<button class="button mt-4 w-full" onclick={() => (document.location.href = '/')}>
{$t('register.startFastButton')}
</button>
</div>
{:else if current_step == 6}
<Typingtest onFinish={onTyping} />
<Typingtest onFinish={onTyping} {user} />
{:else if current_step == 7}
<div class="text-center">
<p class="text-center">
{@html $t('register.start')}
</p>
<button class="button mt-4 m-auto" on:click={() => (document.location.href = '/')}>
<button class="button mt-4 m-auto" onclick={() => (document.location.href = '/')}>
{$t('register.startButton')}
</button>
</div>
......
echo "STARTED UPDATE AT $(date)" > /tmp/docker_update
cd /mnt/data/languagelab/repo/
source app/.env/bin/activate
cd backend
alembic upgrade head
cd /mnt/data/languagelab/repo/
git pull
docker compose up -d
cd scripts/surveys
cd /mnt/data/languagelab/repo/scripts/surveys
python3 survey_maker.py < .creds
echo "END UPDATE AT $(date)" >> /tmp/docker_update
0% Chargement en cours ou .
You are about to add 0 people to the discussion. Proceed with caution.
Terminez d'abord l'édition de ce message.
Veuillez vous inscrire ou vous pour commenter