Spaces:
Running
Running
ultima
Browse files- .gitattributes +0 -35
- .gitignore +1 -0
- Dockerfile +0 -17
- README.md +0 -10
- app.py +9 -12
- requirements.txt +1 -0
- templates/admin/conference_form.html +2 -10
- templates/admin/conferences.html +1 -1
- templates/{dashboard.html → configuracao.html} +2 -14
- templates/index.html +204 -38
- templates/layout.html +23 -4
- test_app.py +112 -0
.gitattributes
DELETED
@@ -1,35 +0,0 @@
|
|
1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
__pycahche__
|
Dockerfile
DELETED
@@ -1,17 +0,0 @@
|
|
1 |
-
# Usa uma imagem Python como base
|
2 |
-
FROM python:3.9
|
3 |
-
|
4 |
-
# Define o diretório de trabalho dentro do contêiner
|
5 |
-
WORKDIR /app
|
6 |
-
|
7 |
-
# Copia os arquivos do projeto para o contêiner
|
8 |
-
COPY . /app
|
9 |
-
|
10 |
-
# Instala as dependências
|
11 |
-
RUN pip install -r requirements.txt
|
12 |
-
|
13 |
-
# Expõe a porta do Flask
|
14 |
-
EXPOSE 5000
|
15 |
-
|
16 |
-
# Comando para rodar o app
|
17 |
-
CMD ["python", "app.py"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
DELETED
@@ -1,10 +0,0 @@
|
|
1 |
-
---
|
2 |
-
title: Eventos V2
|
3 |
-
emoji: 🔥
|
4 |
-
colorFrom: red
|
5 |
-
colorTo: yellow
|
6 |
-
sdk: docker
|
7 |
-
pinned: false
|
8 |
-
---
|
9 |
-
|
10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
@@ -129,7 +129,7 @@ def login():
|
|
129 |
'access_token': response.session.access_token
|
130 |
}
|
131 |
|
132 |
-
return redirect(url_for('
|
133 |
|
134 |
except Exception as e:
|
135 |
return render_template('login.html', error=str(e))
|
@@ -137,9 +137,9 @@ def login():
|
|
137 |
return render_template('login.html')
|
138 |
|
139 |
# Rota para o painel do usuário (protegida)
|
140 |
-
@app.route('/
|
141 |
@login_required
|
142 |
-
def
|
143 |
user = session.get('user')
|
144 |
# Verificar se o usuário é admin
|
145 |
try:
|
@@ -150,7 +150,7 @@ def dashboard():
|
|
150 |
|
151 |
is_admin = True
|
152 |
|
153 |
-
return render_template('
|
154 |
|
155 |
# Rota para logout
|
156 |
@app.route('/logout')
|
@@ -165,14 +165,14 @@ def logout():
|
|
165 |
|
166 |
# Rota para listar conferências (admin)
|
167 |
@app.route('/admin/conferences')
|
168 |
-
|
169 |
def admin_conferences():
|
170 |
conferences = get_conferences_from_db()
|
171 |
return render_template('admin/conferences.html', conferences=conferences)
|
172 |
|
173 |
# Rota para adicionar nova conferência
|
174 |
@app.route('/admin/conferences/new', methods=['GET', 'POST'])
|
175 |
-
|
176 |
def add_conference():
|
177 |
print("entrou aqui")
|
178 |
if request.method == 'POST':
|
@@ -183,7 +183,6 @@ def add_conference():
|
|
183 |
'full_name': request.form.get('full_name'),
|
184 |
'dates': request.form.get('dates'),
|
185 |
'location': request.form.get('location'),
|
186 |
-
'categories': request.form.get('categories').split(','),
|
187 |
'deadline': request.form.get('deadline'),
|
188 |
'website': request.form.get('website'),
|
189 |
'description': request.form.get('description'),
|
@@ -205,7 +204,7 @@ def add_conference():
|
|
205 |
|
206 |
# Rota para editar conferência
|
207 |
@app.route('/admin/conferences/edit/<conference_id>', methods=['GET', 'POST'])
|
208 |
-
|
209 |
def edit_conference(conference_id):
|
210 |
# Obter a conferência pelo ID
|
211 |
conference = get_conference_by_id(conference_id)
|
@@ -222,7 +221,6 @@ def edit_conference(conference_id):
|
|
222 |
'full_name': request.form.get('full_name'),
|
223 |
'dates': request.form.get('dates'),
|
224 |
'location': request.form.get('location'),
|
225 |
-
'categories': request.form.get('categories').split(','),
|
226 |
'deadline': request.form.get('deadline'),
|
227 |
'website': request.form.get('website'),
|
228 |
'description': request.form.get('description'),
|
@@ -243,7 +241,7 @@ def edit_conference(conference_id):
|
|
243 |
|
244 |
# Rota para excluir conferência
|
245 |
@app.route('/admin/conferences/delete/<conference_id>', methods=['POST'])
|
246 |
-
|
247 |
def delete_conference(conference_id):
|
248 |
try:
|
249 |
# Excluir do Supabase
|
@@ -272,5 +270,4 @@ def perfil():
|
|
272 |
return render_template('perfil.html', user=user)
|
273 |
|
274 |
if __name__ == '__main__':
|
275 |
-
app.run(
|
276 |
-
#app.run(debug=True)
|
|
|
129 |
'access_token': response.session.access_token
|
130 |
}
|
131 |
|
132 |
+
return redirect(url_for('configuracao'))
|
133 |
|
134 |
except Exception as e:
|
135 |
return render_template('login.html', error=str(e))
|
|
|
137 |
return render_template('login.html')
|
138 |
|
139 |
# Rota para o painel do usuário (protegida)
|
140 |
+
@app.route('/configuracao')
|
141 |
@login_required
|
142 |
+
def configuracao():
|
143 |
user = session.get('user')
|
144 |
# Verificar se o usuário é admin
|
145 |
try:
|
|
|
150 |
|
151 |
is_admin = True
|
152 |
|
153 |
+
return render_template('configuracao.html', user=user, is_admin=is_admin)
|
154 |
|
155 |
# Rota para logout
|
156 |
@app.route('/logout')
|
|
|
165 |
|
166 |
# Rota para listar conferências (admin)
|
167 |
@app.route('/admin/conferences')
|
168 |
+
#@admin_required
|
169 |
def admin_conferences():
|
170 |
conferences = get_conferences_from_db()
|
171 |
return render_template('admin/conferences.html', conferences=conferences)
|
172 |
|
173 |
# Rota para adicionar nova conferência
|
174 |
@app.route('/admin/conferences/new', methods=['GET', 'POST'])
|
175 |
+
#@admin_required
|
176 |
def add_conference():
|
177 |
print("entrou aqui")
|
178 |
if request.method == 'POST':
|
|
|
183 |
'full_name': request.form.get('full_name'),
|
184 |
'dates': request.form.get('dates'),
|
185 |
'location': request.form.get('location'),
|
|
|
186 |
'deadline': request.form.get('deadline'),
|
187 |
'website': request.form.get('website'),
|
188 |
'description': request.form.get('description'),
|
|
|
204 |
|
205 |
# Rota para editar conferência
|
206 |
@app.route('/admin/conferences/edit/<conference_id>', methods=['GET', 'POST'])
|
207 |
+
#@admin_required
|
208 |
def edit_conference(conference_id):
|
209 |
# Obter a conferência pelo ID
|
210 |
conference = get_conference_by_id(conference_id)
|
|
|
221 |
'full_name': request.form.get('full_name'),
|
222 |
'dates': request.form.get('dates'),
|
223 |
'location': request.form.get('location'),
|
|
|
224 |
'deadline': request.form.get('deadline'),
|
225 |
'website': request.form.get('website'),
|
226 |
'description': request.form.get('description'),
|
|
|
241 |
|
242 |
# Rota para excluir conferência
|
243 |
@app.route('/admin/conferences/delete/<conference_id>', methods=['POST'])
|
244 |
+
#@admin_required
|
245 |
def delete_conference(conference_id):
|
246 |
try:
|
247 |
# Excluir do Supabase
|
|
|
270 |
return render_template('perfil.html', user=user)
|
271 |
|
272 |
if __name__ == '__main__':
|
273 |
+
app.run(debug=True)
|
|
requirements.txt
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
flask==2.3.3
|
2 |
supabase==2.0.3
|
3 |
python-dotenv==1.0.0
|
|
|
4 |
uuid==1.30
|
|
|
1 |
flask==2.3.3
|
2 |
supabase==2.0.3
|
3 |
python-dotenv==1.0.0
|
4 |
+
gunicorn==21.2.0
|
5 |
uuid==1.30
|
templates/admin/conference_form.html
CHANGED
@@ -12,7 +12,7 @@
|
|
12 |
<div class="container mt-4">
|
13 |
<nav aria-label="breadcrumb">
|
14 |
<ol class="breadcrumb">
|
15 |
-
<li class="breadcrumb-item"><a href="{{ url_for('
|
16 |
<li class="breadcrumb-item"><a href="{{ url_for('admin_conferences') }}">Conferências</a></li>
|
17 |
<li class="breadcrumb-item active" aria-current="page">
|
18 |
{% if action == 'add' %}Nova Conferência{% else %}Editar Conferência{% endif %}
|
@@ -65,7 +65,7 @@
|
|
65 |
|
66 |
<div class="row mb-3">
|
67 |
<div class="col-md-6">
|
68 |
-
<label for="deadline" class="form-label">Prazo*</label>
|
69 |
<input type="text" class="form-control" id="deadline" name="deadline"
|
70 |
value="{{ conference.deadline if conference else '' }}" required
|
71 |
placeholder="YYYY-MM-DD HH:MM">
|
@@ -75,14 +75,6 @@
|
|
75 |
</div>
|
76 |
</div>
|
77 |
|
78 |
-
<div class="mb-3">
|
79 |
-
<label for="categories" class="form-label">Categorias*</label>
|
80 |
-
<input type="text" class="form-control" id="categories" name="categories"
|
81 |
-
value="{% if conference %}{{ conference.categories|join(',') }}{% endif %}" required
|
82 |
-
placeholder="machine-learning, natural-language-processing">
|
83 |
-
<div class="form-text">Separadas por vírgula, sem espaços.</div>
|
84 |
-
</div>
|
85 |
-
|
86 |
<div class="mb-3">
|
87 |
<label for="website" class="form-label">Website*</label>
|
88 |
<input type="text" class="form-control" id="website" name="website"
|
|
|
12 |
<div class="container mt-4">
|
13 |
<nav aria-label="breadcrumb">
|
14 |
<ol class="breadcrumb">
|
15 |
+
<li class="breadcrumb-item"><a href="{{ url_for('configuracao') }}">Configuração</a></li>
|
16 |
<li class="breadcrumb-item"><a href="{{ url_for('admin_conferences') }}">Conferências</a></li>
|
17 |
<li class="breadcrumb-item active" aria-current="page">
|
18 |
{% if action == 'add' %}Nova Conferência{% else %}Editar Conferência{% endif %}
|
|
|
65 |
|
66 |
<div class="row mb-3">
|
67 |
<div class="col-md-6">
|
68 |
+
<label for="deadline" class="form-label">Prazo da submissão*</label>
|
69 |
<input type="text" class="form-control" id="deadline" name="deadline"
|
70 |
value="{{ conference.deadline if conference else '' }}" required
|
71 |
placeholder="YYYY-MM-DD HH:MM">
|
|
|
75 |
</div>
|
76 |
</div>
|
77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
<div class="mb-3">
|
79 |
<label for="website" class="form-label">Website*</label>
|
80 |
<input type="text" class="form-control" id="website" name="website"
|
templates/admin/conferences.html
CHANGED
@@ -93,7 +93,7 @@
|
|
93 |
</div>
|
94 |
|
95 |
<div class="mt-3">
|
96 |
-
<a href="{{ url_for('
|
97 |
</div>
|
98 |
</div>
|
99 |
{% endblock %}
|
|
|
93 |
</div>
|
94 |
|
95 |
<div class="mt-3">
|
96 |
+
<a href="{{ url_for('configuracao') }}" class="btn btn-secondary">Voltar para configuração</a>
|
97 |
</div>
|
98 |
</div>
|
99 |
{% endblock %}
|
templates/{dashboard.html → configuracao.html}
RENAMED
@@ -1,30 +1,18 @@
|
|
1 |
{% extends "layout.html" %}
|
2 |
|
3 |
-
{% block title %}
|
4 |
|
5 |
{% block content %}
|
6 |
<div class="row">
|
7 |
<div class="col-md-12">
|
8 |
<div class="card">
|
9 |
<div class="card-header">
|
10 |
-
<h2>
|
11 |
</div>
|
12 |
<div class="card-body">
|
13 |
<h5 class="card-title">Bem-vindo, {{ user.email }}!</h5>
|
14 |
<p class="card-text">Você está autenticado com sucesso!</p>
|
15 |
|
16 |
-
<div class="mt-4">
|
17 |
-
<h6>Suas informações:</h6>
|
18 |
-
<ul class="list-group">
|
19 |
-
<li class="list-group-item">
|
20 |
-
<strong>ID:</strong> {{ user.id }}
|
21 |
-
</li>
|
22 |
-
<li class="list-group-item">
|
23 |
-
<strong>Email:</strong> {{ user.email }}
|
24 |
-
</li>
|
25 |
-
</ul>
|
26 |
-
</div>
|
27 |
-
|
28 |
{% if is_admin %}
|
29 |
<div class="mt-4">
|
30 |
<h6>Recursos de Administrador:</h6>
|
|
|
1 |
{% extends "layout.html" %}
|
2 |
|
3 |
+
{% block title %}configuracao - Flask Supabase Auth{% endblock %}
|
4 |
|
5 |
{% block content %}
|
6 |
<div class="row">
|
7 |
<div class="col-md-12">
|
8 |
<div class="card">
|
9 |
<div class="card-header">
|
10 |
+
<h2>Configuração</h2>
|
11 |
</div>
|
12 |
<div class="card-body">
|
13 |
<h5 class="card-title">Bem-vindo, {{ user.email }}!</h5>
|
14 |
<p class="card-text">Você está autenticado com sucesso!</p>
|
15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
{% if is_admin %}
|
17 |
<div class="mt-4">
|
18 |
<h6>Recursos de Administrador:</h6>
|
templates/index.html
CHANGED
@@ -3,49 +3,45 @@
|
|
3 |
{% block title %}Home - Flask Supabase Auth{% endblock %}
|
4 |
|
5 |
{% block content %}
|
6 |
-
<div class="p-5 mb-4 bg-light rounded-3">
|
7 |
-
<div class="container-fluid py-5 text-center">
|
8 |
-
<h1 class="display-5 fw-bold">AI Conferences deadline</h1>
|
9 |
-
<p class="fs-4">Seu calendário completo de eventos em Ciência da Computação.</p>
|
10 |
-
{% if not session.get('user') %}
|
11 |
-
<div class="mt-4">
|
12 |
-
<a href="{{ url_for('signup') }}" class="btn btn-primary btn-lg mx-2">Registrar</a>
|
13 |
-
<a href="{{ url_for('login') }}" class="btn btn-outline-primary btn-lg mx-2">Entrar</a>
|
14 |
-
</div>
|
15 |
-
{% else %}
|
16 |
-
<div class="mt-4">
|
17 |
-
<a href="{{ url_for('dashboard') }}" class="btn btn-success btn-lg">Acessar Dashboard</a>
|
18 |
-
</div>
|
19 |
-
{% endif %}
|
20 |
-
</div>
|
21 |
-
</div>
|
22 |
|
23 |
<!-- Seção de Eventos/Conferências -->
|
24 |
<div class="container mt-5">
|
25 |
-
<
|
26 |
-
|
27 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
<thead class="table-dark">
|
29 |
<tr>
|
30 |
-
<th>Nome
|
31 |
-
<th>Datas
|
32 |
-
<th>Localização
|
33 |
-
<th>Prazo
|
34 |
-
<th>Tempo
|
35 |
-
<th>Ações</th>
|
36 |
</tr>
|
37 |
</thead>
|
38 |
<tbody>
|
39 |
{% for conference in conferences %}
|
40 |
<tr>
|
41 |
-
<td>{{ conference.name }} - {{ conference.full_name }}</td>
|
42 |
-
<td>{{ conference.dates }}</td>
|
43 |
-
<td>{{ conference.location }}</td>
|
44 |
-
<td>{{ conference.deadline }}</td>
|
45 |
-
<td class="countdown-cell" data-deadline="{{ conference.deadline }}">
|
46 |
<span class="countdown-value">Calculando...</span>
|
47 |
</td>
|
48 |
-
<td>
|
49 |
<a href="{{ url_for('conference_details', conference_id=conference.id) }}"
|
50 |
class="btn btn-primary btn-sm">Ver Detalhes</a>
|
51 |
</td>
|
@@ -54,17 +50,83 @@
|
|
54 |
</tbody>
|
55 |
</table>
|
56 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
</div>
|
58 |
|
59 |
<script>
|
60 |
document.addEventListener('DOMContentLoaded', function() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
// Atualizar os contadores de tempo restante
|
62 |
function updateCountdowns() {
|
63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
|
65 |
-
|
66 |
-
|
67 |
-
|
|
|
|
|
|
|
|
|
|
|
68 |
|
69 |
// Verificar se a data é válida
|
70 |
if (!deadlineStr) {
|
@@ -87,7 +149,7 @@
|
|
87 |
|
88 |
if (timeDiff <= 0) {
|
89 |
countdownElement.textContent = 'Prazo encerrado';
|
90 |
-
|
91 |
} else {
|
92 |
// Calcular dias, horas, minutos restantes
|
93 |
const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
|
@@ -103,9 +165,9 @@
|
|
103 |
|
104 |
// Adicionar classes para destacar prazos próximos
|
105 |
if (days < 1) {
|
106 |
-
|
107 |
} else if (days < 7) {
|
108 |
-
|
109 |
}
|
110 |
}
|
111 |
} catch (error) {
|
@@ -118,6 +180,110 @@
|
|
118 |
// Executar imediatamente e depois a cada minuto
|
119 |
updateCountdowns();
|
120 |
setInterval(updateCountdowns, 60000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
});
|
122 |
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
{% endblock %}
|
|
|
3 |
{% block title %}Home - Flask Supabase Auth{% endblock %}
|
4 |
|
5 |
{% block content %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
<!-- Seção de Eventos/Conferências -->
|
8 |
<div class="container mt-5">
|
9 |
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
10 |
+
<h2 class="mb-0">Eventos Disponíveis</h2>
|
11 |
+
<div class="btn-group" role="group" aria-label="Opções de visualização">
|
12 |
+
<button type="button" class="btn btn-outline-primary view-toggle active" data-view="table">
|
13 |
+
<i class="bi bi-table"></i> Tabela
|
14 |
+
</button>
|
15 |
+
<button type="button" class="btn btn-outline-primary view-toggle" data-view="cards">
|
16 |
+
<i class="bi bi-grid-3x3-gap"></i> Cards
|
17 |
+
</button>
|
18 |
+
</div>
|
19 |
+
</div>
|
20 |
+
|
21 |
+
<!-- Visualização em Tabela -->
|
22 |
+
<div class="table-responsive view-content" id="tableView">
|
23 |
+
<table class="table table-hover" id="conferencesTable">
|
24 |
<thead class="table-dark">
|
25 |
<tr>
|
26 |
+
<th class="sortable text-start align-middle bg-primary" data-sort="name">Nome<i class="sort-icon"></i></th>
|
27 |
+
<th class="sortable text-start align-middle bg-primary" data-sort="dates">Datas realização<i class="sort-icon"></i></th>
|
28 |
+
<th class="sortable text-start align-middle bg-primary" data-sort="location">Localização<i class="sort-icon"></i></th>
|
29 |
+
<th class="sortable text-start align-middle bg-primary" data-sort="deadline">Prazo submissão<i class="sort-icon"></i></th>
|
30 |
+
<th class="sortable text-start align-middle bg-primary" data-sort="countdown">Tempo restante para submissão<i class="sort-icon"></i></th>
|
31 |
+
<th class="text-start align-middle bg-primary">Ações</th>
|
32 |
</tr>
|
33 |
</thead>
|
34 |
<tbody>
|
35 |
{% for conference in conferences %}
|
36 |
<tr>
|
37 |
+
<td data-value="{{ conference.name }}" class="align-middle">{{ conference.name }} - {{ conference.full_name }}</td>
|
38 |
+
<td data-value="{{ conference.dates }}" class="align-middle">{{ conference.dates }}</td>
|
39 |
+
<td data-value="{{ conference.location }}" class="align-middle">{{ conference.location }}</td>
|
40 |
+
<td data-value="{{ conference.deadline }}" class="align-middle">{{ conference.deadline }}</td>
|
41 |
+
<td class="countdown-cell align-middle" data-deadline="{{ conference.deadline }}" data-value="{{ conference.deadline }}">
|
42 |
<span class="countdown-value">Calculando...</span>
|
43 |
</td>
|
44 |
+
<td class="align-middle">
|
45 |
<a href="{{ url_for('conference_details', conference_id=conference.id) }}"
|
46 |
class="btn btn-primary btn-sm">Ver Detalhes</a>
|
47 |
</td>
|
|
|
50 |
</tbody>
|
51 |
</table>
|
52 |
</div>
|
53 |
+
|
54 |
+
<!-- Visualização em Cards -->
|
55 |
+
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 view-content" id="cardsView" style="display: none;">
|
56 |
+
{% for conference in conferences %}
|
57 |
+
<div class="col">
|
58 |
+
<div class="card h-100 shadow-sm">
|
59 |
+
<div class="card-header bg-primary text-white">
|
60 |
+
<h5 class="card-title mb-0">{{ conference.name }}</h5>
|
61 |
+
</div>
|
62 |
+
<div class="card-body">
|
63 |
+
<h6 class="card-subtitle mb-2 text-muted">{{ conference.full_name }}</h6>
|
64 |
+
<p class="card-text"><strong>Datas:</strong> {{ conference.dates }}</p>
|
65 |
+
<p class="card-text"><strong>Localização:</strong> {{ conference.location }}</p>
|
66 |
+
<p class="card-text"><strong>Prazo Submissão:</strong> {{ conference.deadline }}</p>
|
67 |
+
<p class="card-text">
|
68 |
+
<strong>Tempo Restante:</strong><br>
|
69 |
+
<span class="countdown-value card-countdown" data-deadline="{{ conference.deadline }}">Calculando...</span>
|
70 |
+
</p>
|
71 |
+
</div>
|
72 |
+
<div class="card-footer bg-transparent border-top-0">
|
73 |
+
<a href="{{ url_for('conference_details', conference_id=conference.id) }}"
|
74 |
+
class="btn btn-primary btn-sm w-100">Ver Detalhes</a>
|
75 |
+
</div>
|
76 |
+
</div>
|
77 |
+
</div>
|
78 |
+
{% endfor %}
|
79 |
+
</div>
|
80 |
</div>
|
81 |
|
82 |
<script>
|
83 |
document.addEventListener('DOMContentLoaded', function() {
|
84 |
+
// Configuração de alternância de visualização
|
85 |
+
const viewToggles = document.querySelectorAll('.view-toggle');
|
86 |
+
const tableView = document.getElementById('tableView');
|
87 |
+
const cardsView = document.getElementById('cardsView');
|
88 |
+
|
89 |
+
viewToggles.forEach(toggle => {
|
90 |
+
toggle.addEventListener('click', function() {
|
91 |
+
// Remover classe active de todos os botões
|
92 |
+
viewToggles.forEach(btn => btn.classList.remove('active'));
|
93 |
+
// Adicionar classe active ao botão clicado
|
94 |
+
this.classList.add('active');
|
95 |
+
|
96 |
+
// Alternar visualização com base no botão clicado
|
97 |
+
const view = this.getAttribute('data-view');
|
98 |
+
if (view === 'table') {
|
99 |
+
tableView.style.display = 'block';
|
100 |
+
cardsView.style.display = 'none';
|
101 |
+
} else {
|
102 |
+
tableView.style.display = 'none';
|
103 |
+
cardsView.style.display = 'flex';
|
104 |
+
}
|
105 |
+
|
106 |
+
// Atualizar os contadores
|
107 |
+
updateCountdowns();
|
108 |
+
});
|
109 |
+
});
|
110 |
+
|
111 |
// Atualizar os contadores de tempo restante
|
112 |
function updateCountdowns() {
|
113 |
+
// Atualizar para células na tabela
|
114 |
+
updateCountdownElements('.countdown-cell .countdown-value');
|
115 |
+
// Atualizar para cards
|
116 |
+
updateCountdownElements('.card-countdown');
|
117 |
+
}
|
118 |
+
|
119 |
+
function updateCountdownElements(selector) {
|
120 |
+
const countdownElements = document.querySelectorAll(selector);
|
121 |
|
122 |
+
countdownElements.forEach(countdownElement => {
|
123 |
+
// Pegar o deadline do elemento pai (célula da tabela) ou do próprio elemento (card)
|
124 |
+
let deadlineStr;
|
125 |
+
if (countdownElement.classList.contains('card-countdown')) {
|
126 |
+
deadlineStr = countdownElement.getAttribute('data-deadline');
|
127 |
+
} else {
|
128 |
+
deadlineStr = countdownElement.closest('.countdown-cell').getAttribute('data-deadline');
|
129 |
+
}
|
130 |
|
131 |
// Verificar se a data é válida
|
132 |
if (!deadlineStr) {
|
|
|
149 |
|
150 |
if (timeDiff <= 0) {
|
151 |
countdownElement.textContent = 'Prazo encerrado';
|
152 |
+
countdownElement.classList.add('text-danger');
|
153 |
} else {
|
154 |
// Calcular dias, horas, minutos restantes
|
155 |
const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
|
|
|
165 |
|
166 |
// Adicionar classes para destacar prazos próximos
|
167 |
if (days < 1) {
|
168 |
+
countdownElement.classList.add('text-danger', 'fw-bold');
|
169 |
} else if (days < 7) {
|
170 |
+
countdownElement.classList.add('text-warning');
|
171 |
}
|
172 |
}
|
173 |
} catch (error) {
|
|
|
180 |
// Executar imediatamente e depois a cada minuto
|
181 |
updateCountdowns();
|
182 |
setInterval(updateCountdowns, 60000);
|
183 |
+
|
184 |
+
// Configuração da ordenação da tabela
|
185 |
+
const table = document.getElementById('conferencesTable');
|
186 |
+
const headers = table.querySelectorAll('th.sortable');
|
187 |
+
let currentSort = { column: null, direction: 'asc' };
|
188 |
+
|
189 |
+
// Adicionar eventos de clique aos cabeçalhos
|
190 |
+
headers.forEach(header => {
|
191 |
+
header.addEventListener('click', () => {
|
192 |
+
const column = header.dataset.sort;
|
193 |
+
const direction = column === currentSort.column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
194 |
+
|
195 |
+
// Atualizar ícones
|
196 |
+
headers.forEach(h => h.querySelector('.sort-icon').textContent = '');
|
197 |
+
header.querySelector('.sort-icon').textContent = direction === 'asc' ? ' ▲' : ' ▼';
|
198 |
+
|
199 |
+
// Ordenar tabela
|
200 |
+
sortTable(column, direction);
|
201 |
+
|
202 |
+
// Atualizar estado de ordenação atual
|
203 |
+
currentSort = { column, direction };
|
204 |
+
});
|
205 |
+
});
|
206 |
+
|
207 |
+
function sortTable(column, direction) {
|
208 |
+
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
209 |
+
|
210 |
+
// Ordenar linhas
|
211 |
+
const sortedRows = rows.sort((a, b) => {
|
212 |
+
const cellA = a.querySelector(`td[data-value]:nth-child(${getColumnIndex(column) + 1})`);
|
213 |
+
const cellB = b.querySelector(`td[data-value]:nth-child(${getColumnIndex(column) + 1})`);
|
214 |
+
|
215 |
+
let valueA = cellA ? cellA.getAttribute('data-value') : '';
|
216 |
+
let valueB = cellB ? cellB.getAttribute('data-value') : '';
|
217 |
+
|
218 |
+
// Ordenação para datas
|
219 |
+
if (column === 'deadline' || column === 'dates') {
|
220 |
+
valueA = valueA ? new Date(valueA).getTime() : 0;
|
221 |
+
valueB = valueB ? new Date(valueB).getTime() : 0;
|
222 |
+
}
|
223 |
+
|
224 |
+
// Ordenação para strings
|
225 |
+
if (typeof valueA === 'string' && typeof valueB === 'string') {
|
226 |
+
return direction === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
|
227 |
+
}
|
228 |
+
|
229 |
+
// Ordenação para números
|
230 |
+
return direction === 'asc' ? valueA - valueB : valueB - valueA;
|
231 |
+
});
|
232 |
+
|
233 |
+
// Limpar e recriar a tabela
|
234 |
+
const tbody = table.querySelector('tbody');
|
235 |
+
while (tbody.firstChild) {
|
236 |
+
tbody.removeChild(tbody.firstChild);
|
237 |
+
}
|
238 |
+
|
239 |
+
sortedRows.forEach(row => {
|
240 |
+
tbody.appendChild(row);
|
241 |
+
});
|
242 |
+
}
|
243 |
+
|
244 |
+
function getColumnIndex(columnName) {
|
245 |
+
let index = 0;
|
246 |
+
headers.forEach((header, i) => {
|
247 |
+
if (header.dataset.sort === columnName) {
|
248 |
+
index = i;
|
249 |
+
}
|
250 |
+
});
|
251 |
+
return index;
|
252 |
+
}
|
253 |
});
|
254 |
</script>
|
255 |
+
|
256 |
+
<style>
|
257 |
+
.sortable {
|
258 |
+
cursor: pointer;
|
259 |
+
user-select: none;
|
260 |
+
}
|
261 |
+
|
262 |
+
.sortable:hover {
|
263 |
+
background-color: #2c3034;
|
264 |
+
}
|
265 |
+
|
266 |
+
.sort-icon {
|
267 |
+
display: inline-block;
|
268 |
+
width: 10px;
|
269 |
+
}
|
270 |
+
|
271 |
+
.view-toggle.active {
|
272 |
+
background-color: #0d6efd;
|
273 |
+
color: white;
|
274 |
+
}
|
275 |
+
|
276 |
+
#cardsView {
|
277 |
+
display: flex;
|
278 |
+
flex-wrap: wrap;
|
279 |
+
}
|
280 |
+
|
281 |
+
.card {
|
282 |
+
transition: transform 0.2s;
|
283 |
+
}
|
284 |
+
|
285 |
+
.card:hover {
|
286 |
+
transform: translateY(-5px);
|
287 |
+
}
|
288 |
+
</style>
|
289 |
{% endblock %}
|
templates/layout.html
CHANGED
@@ -3,24 +3,37 @@
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
-
<title>{% block title %}
|
7 |
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
8 |
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
9 |
<style>
|
10 |
body {
|
11 |
padding-top: 5rem;
|
|
|
|
|
|
|
12 |
}
|
13 |
.form-container {
|
14 |
max-width: 500px;
|
15 |
margin: 0 auto;
|
16 |
padding: 1rem;
|
17 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
</style>
|
19 |
</head>
|
20 |
<body>
|
21 |
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
22 |
<div class="container">
|
23 |
-
<a class="navbar-brand" href="/">
|
24 |
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
25 |
<span class="navbar-toggler-icon"></span>
|
26 |
</button>
|
@@ -28,7 +41,7 @@
|
|
28 |
<ul class="navbar-nav ms-auto">
|
29 |
{% if session.get('user') %}
|
30 |
<li class="nav-item">
|
31 |
-
<a class="nav-link" href="{{ url_for('
|
32 |
</li>
|
33 |
<li class="nav-item">
|
34 |
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
@@ -38,7 +51,7 @@
|
|
38 |
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
39 |
</li>
|
40 |
<li class="nav-item">
|
41 |
-
<a class="nav-link" href="{{ url_for('signup') }}">
|
42 |
</li>
|
43 |
{% endif %}
|
44 |
</ul>
|
@@ -50,6 +63,12 @@
|
|
50 |
{% block content %}{% endblock %}
|
51 |
</main>
|
52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
54 |
</body>
|
55 |
</html>
|
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>{% block title %}Calendário de Eventos{% endblock %}</title>
|
7 |
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
8 |
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
9 |
<style>
|
10 |
body {
|
11 |
padding-top: 5rem;
|
12 |
+
min-height: 100vh;
|
13 |
+
display: flex;
|
14 |
+
flex-direction: column;
|
15 |
}
|
16 |
.form-container {
|
17 |
max-width: 500px;
|
18 |
margin: 0 auto;
|
19 |
padding: 1rem;
|
20 |
}
|
21 |
+
main {
|
22 |
+
flex: 1 0 auto;
|
23 |
+
}
|
24 |
+
footer {
|
25 |
+
margin-top: 2rem;
|
26 |
+
padding: 1rem 0;
|
27 |
+
background-color: #f8f9fa;
|
28 |
+
text-align: center;
|
29 |
+
border-top: 1px solid #e9ecef;
|
30 |
+
}
|
31 |
</style>
|
32 |
</head>
|
33 |
<body>
|
34 |
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
35 |
<div class="container">
|
36 |
+
<a class="navbar-brand" href="/">Calendário de Eventos</a>
|
37 |
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
38 |
<span class="navbar-toggler-icon"></span>
|
39 |
</button>
|
|
|
41 |
<ul class="navbar-nav ms-auto">
|
42 |
{% if session.get('user') %}
|
43 |
<li class="nav-item">
|
44 |
+
<a class="nav-link" href="{{ url_for('configuracao') }}">Configuração</a>
|
45 |
</li>
|
46 |
<li class="nav-item">
|
47 |
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
|
|
51 |
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
52 |
</li>
|
53 |
<li class="nav-item">
|
54 |
+
<a class="nav-link" href="{{ url_for('signup') }}">Criar conta</a>
|
55 |
</li>
|
56 |
{% endif %}
|
57 |
</ul>
|
|
|
63 |
{% block content %}{% endblock %}
|
64 |
</main>
|
65 |
|
66 |
+
<footer class="footer">
|
67 |
+
<div class="container">
|
68 |
+
<p class="text-muted">Criado por Giseldo Neo 2025</p>
|
69 |
+
</div>
|
70 |
+
</footer>
|
71 |
+
|
72 |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
73 |
</body>
|
74 |
</html>
|
test_app.py
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import unittest
|
2 |
+
from unittest.mock import patch, MagicMock
|
3 |
+
from flask import Flask
|
4 |
+
import json
|
5 |
+
from app import app
|
6 |
+
|
7 |
+
class TestAddConference(unittest.TestCase):
|
8 |
+
|
9 |
+
def setUp(self):
|
10 |
+
self.app = app
|
11 |
+
self.app.config['TESTING'] = True
|
12 |
+
self.client = self.app.test_client()
|
13 |
+
# Criar sessão fictícia para simular usuário logado
|
14 |
+
with self.client.session_transaction() as sess:
|
15 |
+
sess['user'] = {
|
16 |
+
'id': 'test-user-id',
|
17 |
+
'email': '[email protected]',
|
18 |
+
'access_token': 'fake-token'
|
19 |
+
}
|
20 |
+
|
21 |
+
@patch('app.supabase')
|
22 |
+
def test_add_conference_success(self, mock_supabase):
|
23 |
+
# Configurar o mock do Supabase para simular sucesso na inserção
|
24 |
+
mock_response = MagicMock()
|
25 |
+
mock_response.data = [{"id": "test123"}]
|
26 |
+
mock_supabase.table.return_value.insert.return_value.execute.return_value = mock_response
|
27 |
+
|
28 |
+
# Dados de conferência para teste
|
29 |
+
conference_data = {
|
30 |
+
'id': 'test123',
|
31 |
+
'name': 'ICML',
|
32 |
+
'full_name': 'International Conference on Machine Learning',
|
33 |
+
'dates': '10-15 July 2024',
|
34 |
+
'location': 'Vienna, Austria',
|
35 |
+
'categories': 'machine-learning, artificial-intelligence',
|
36 |
+
'deadline': '2024-01-30 23:59',
|
37 |
+
'website': 'icml2024.org',
|
38 |
+
'description': 'A leading international academic conference in machine learning.'
|
39 |
+
}
|
40 |
+
|
41 |
+
# Fazer a requisição POST para adicionar uma conferência
|
42 |
+
response = self.client.post('/admin/conferences/new',
|
43 |
+
data=conference_data,
|
44 |
+
follow_redirects=True)
|
45 |
+
|
46 |
+
# Verificar se o status da resposta é 200 OK
|
47 |
+
self.assertEqual(response.status_code, 200)
|
48 |
+
|
49 |
+
# Verificar se o Supabase foi chamado corretamente
|
50 |
+
mock_supabase.table.assert_called_once_with('conferences')
|
51 |
+
mock_supabase.table().insert.assert_called_once()
|
52 |
+
|
53 |
+
# Verificar se a resposta contém a mensagem de sucesso
|
54 |
+
self.assertIn(b'Confer\xc3\xaancia adicionada com sucesso!', response.data)
|
55 |
+
|
56 |
+
@patch('app.supabase')
|
57 |
+
def test_add_conference_error(self, mock_supabase):
|
58 |
+
# Configurar o mock para simular um erro
|
59 |
+
mock_supabase.table.return_value.insert.return_value.execute.side_effect = Exception('Erro de teste')
|
60 |
+
|
61 |
+
# Dados incompletos para provocar erro
|
62 |
+
conference_data = {
|
63 |
+
'name': 'ICML',
|
64 |
+
'full_name': 'International Conference on Machine Learning',
|
65 |
+
# outros campos omitidos propositalmente
|
66 |
+
}
|
67 |
+
|
68 |
+
# Fazer a requisição POST
|
69 |
+
response = self.client.post('/admin/conferences/new',
|
70 |
+
data=conference_data,
|
71 |
+
follow_redirects=True)
|
72 |
+
|
73 |
+
# Verificar se a resposta contém a mensagem de erro
|
74 |
+
self.assertIn(b'Erro ao adicionar confer\xc3\xaancia: Erro de teste', response.data)
|
75 |
+
|
76 |
+
@patch('app.supabase')
|
77 |
+
def test_add_conference_with_auto_id(self, mock_supabase):
|
78 |
+
# Configurar o mock do Supabase
|
79 |
+
mock_response = MagicMock()
|
80 |
+
mock_response.data = [{"id": "auto123"}]
|
81 |
+
mock_supabase.table.return_value.insert.return_value.execute.return_value = mock_response
|
82 |
+
|
83 |
+
# Dados sem ID (deve ser gerado automaticamente)
|
84 |
+
conference_data = {
|
85 |
+
'name': 'NeurIPS',
|
86 |
+
'full_name': 'Neural Information Processing Systems',
|
87 |
+
'dates': '9-15 December 2024',
|
88 |
+
'location': 'Vancouver, Canada',
|
89 |
+
'categories': 'neural-networks,deep-learning',
|
90 |
+
'deadline': '2024-05-17 23:59',
|
91 |
+
'website': 'neurips2024.cc',
|
92 |
+
'description': 'Leading conference on neural networks and deep learning'
|
93 |
+
}
|
94 |
+
|
95 |
+
# Fazer a requisição POST
|
96 |
+
response = self.client.post('/admin/conferences/new',
|
97 |
+
data=conference_data,
|
98 |
+
follow_redirects=True)
|
99 |
+
|
100 |
+
# Verificar se o status da resposta é 200 OK
|
101 |
+
self.assertEqual(response.status_code, 200)
|
102 |
+
|
103 |
+
# Verificar que o método para inserir no Supabase foi chamado
|
104 |
+
mock_supabase.table.assert_called_once_with('conferences')
|
105 |
+
mock_supabase.table().insert.assert_called_once()
|
106 |
+
|
107 |
+
# Verificamos que um ID foi gerado (não podemos checar o valor exato pois é aleatório)
|
108 |
+
call_args = mock_supabase.table().insert.call_args[0][0]
|
109 |
+
self.assertIsNotNone(call_args.get('id'))
|
110 |
+
|
111 |
+
if __name__ == '__main__':
|
112 |
+
unittest.main()
|