forked from ScoDoc/DocScoDoc
WIP: BUT validations/parcours.
This commit is contained in:
parent
6596bd778c
commit
b25ba0bc39
@ -78,4 +78,6 @@ from app.models.but_refcomp import (
|
||||
ApcAppCritique,
|
||||
ApcParcours,
|
||||
)
|
||||
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
||||
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
|
100
app/models/but_validations.py
Normal file
100
app/models/but_validations.py
Normal file
@ -0,0 +1,100 @@
|
||||
# -*- coding: UTF-8 -*
|
||||
|
||||
"""Décisions de jury validations) des RCUE et années du BUT
|
||||
"""
|
||||
|
||||
from app import db
|
||||
from app import log
|
||||
|
||||
from app.models import CODE_STR_LEN
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
|
||||
|
||||
class ApcValidationRCUE(db.Model):
|
||||
"""Validation des niveaux de compétences
|
||||
|
||||
aka "regroupements cohérents d'UE" dans le jargon BUT.
|
||||
|
||||
"""
|
||||
|
||||
__tablename__ = "apc_validation_rcue"
|
||||
# Assure unicité de la décision:
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("etudid", "formsemestre_id", "ue1_id", "ue2_id"),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
|
||||
)
|
||||
# Les deux UE associées à ce niveau:
|
||||
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
|
||||
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
|
||||
# optionnel, le parcours dans lequel se trouve la compétence:
|
||||
parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True)
|
||||
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
|
||||
|
||||
etud = db.relationship("Identite", backref="apc_validations_rcues")
|
||||
formsemestre = db.relationship("FormSemestre", backref="apc_validations_rcues")
|
||||
ue1 = db.relationship("UniteEns", foreign_keys=ue1_id)
|
||||
ue2 = db.relationship("UniteEns", foreign_keys=ue2_id)
|
||||
parcour = db.relationship("ApcParcours")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>"
|
||||
|
||||
|
||||
def get_other_ue_rcue(ue: UniteEns, etudid: int) -> UniteEns:
|
||||
"""L'autre UE du RCUE (niveau de compétence) pour cet étudiant,
|
||||
None si pas trouvée.
|
||||
"""
|
||||
if (ue.niveau_competence is None) or (ue.semestre_idx is None):
|
||||
return None
|
||||
q = UniteEns.query.filter(
|
||||
FormSemestreInscription.etudid == etudid,
|
||||
FormSemestreInscription.formsemestre_id == FormSemestre.id,
|
||||
FormSemestre.formation_id == UniteEns.formation_id,
|
||||
FormSemestre.semestre_id == UniteEns.semestre_idx,
|
||||
UniteEns.niveau_competence_id == ue.niveau_competence_id,
|
||||
UniteEns.semestre_idx != ue.semestre_idx,
|
||||
)
|
||||
if q.count() > 1:
|
||||
log("Warning: get_other_ue_rcue: {q.count()} candidates UE")
|
||||
return q.first()
|
||||
|
||||
|
||||
class ApcValidationAnnee(db.Model):
|
||||
"""Validation des années du BUT"""
|
||||
|
||||
__tablename__ = "apc_validation_annee"
|
||||
# Assure unicité de la décision:
|
||||
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
ordre = db.Column(db.Integer, nullable=False)
|
||||
"numéro de l'année: 1, 2, 3"
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
|
||||
)
|
||||
annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
|
||||
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
|
||||
|
||||
etud = db.relationship("Identite", backref="apc_validations_annees")
|
||||
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}:{self.code!r}>"
|
@ -1,10 +1,17 @@
|
||||
"""ScoDoc 9 models : Formations
|
||||
"""
|
||||
import flask_sqlalchemy
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.comp import df_cache
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.but_refcomp import (
|
||||
ApcAnneeParcours,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcParcoursNiveauCompetence,
|
||||
)
|
||||
from app.models.modules import Module
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_cache
|
||||
@ -148,6 +155,18 @@ class Formation(db.Model):
|
||||
if change:
|
||||
app.clear_scodoc_cache()
|
||||
|
||||
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery::
|
||||
"""Les UEs d'un parcours de la formation.
|
||||
Exemple: pour avoir les UE du semestre 3, faire
|
||||
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
|
||||
"""
|
||||
return UniteEns.query.filter_by(formation=self).filter(
|
||||
UniteEns.niveau_competence_id == ApcNiveau.id,
|
||||
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
|
||||
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
|
||||
ApcAnneeParcours.parcours_id == parcour.id,
|
||||
)
|
||||
|
||||
|
||||
class Matiere(db.Model):
|
||||
"""Matières: regroupe les modules d'une UE
|
||||
|
@ -14,6 +14,12 @@ from app import log
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models import CODE_STR_LEN
|
||||
from app.models.but_refcomp import (
|
||||
ApcAnneeParcours,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcParcoursNiveauCompetence,
|
||||
)
|
||||
from app.models.groups import GroupDescr, Partition
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
@ -211,6 +217,22 @@ class FormSemestre(db.Model):
|
||||
sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
|
||||
return sem_ues.order_by(UniteEns.numero)
|
||||
|
||||
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
|
||||
"""UE que suit l'étudiant dans ce semestre BUT
|
||||
en fonction du parcours dans lequel il est inscrit.
|
||||
|
||||
Si voulez les UE d'un parcour, il est plus efficace de passer par
|
||||
`formation.query_ues_parcour(parcour)`.
|
||||
"""
|
||||
return self.query_ues().filter(
|
||||
FormSemestreInscription.etudid == etudid,
|
||||
FormSemestreInscription.formsemestre == self,
|
||||
UniteEns.niveau_competence_id == ApcNiveau.id,
|
||||
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
|
||||
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
|
||||
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def modimpls_sorted(self) -> list[ModuleImpl]:
|
||||
"""Liste des modimpls du semestre (y compris bonus)
|
||||
|
@ -35,7 +35,7 @@ from app import log
|
||||
|
||||
@enum.unique
|
||||
class CodesParcours(enum.IntEnum):
|
||||
"""Codes numériques de sparcours, enregistrés en base
|
||||
"""Codes numériques des parcours, enregistrés en base
|
||||
dans notes_formations.type_parcours
|
||||
Ne pas modifier.
|
||||
"""
|
||||
@ -122,10 +122,12 @@ ATJ = "ATJ" # pb assiduité: décision repoussée au semestre suivant
|
||||
ATB = "ATB"
|
||||
AJ = "AJ"
|
||||
CMP = "CMP" # utile pour UE seulement (indique UE acquise car semestre acquis)
|
||||
NAR = "NAR"
|
||||
RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée
|
||||
DEF = "DEF" # défaillance (n'est pas un code jury dans scodoc mais un état, comme inscrit ou demission)
|
||||
DEM = "DEM"
|
||||
JSD = "JSD" # jurytenu mais pas de code (Jury Sans Décision)
|
||||
NAR = "NAR"
|
||||
RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée
|
||||
|
||||
|
||||
# codes actions
|
||||
REDOANNEE = "REDOANNEE" # redouble annee (va en Sn-1)
|
||||
@ -156,9 +158,7 @@ CODES_EXPL = {
|
||||
RAT: "En attente d'un rattrapage",
|
||||
DEM: "Démission",
|
||||
}
|
||||
# Nota: ces explications sont personnalisables via le fichier
|
||||
# de config locale /opt/scodoc/var/scodoc/config/scodoc_local.py
|
||||
# variable: CONFIG.CODES_EXP
|
||||
|
||||
|
||||
# Les codes de semestres:
|
||||
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
|
||||
@ -169,6 +169,9 @@ CODES_SEM_REO = {NAR: 1} # reorientation
|
||||
|
||||
CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée
|
||||
|
||||
# Pour le BUT:
|
||||
CODES_RCUE = {ADM, AJ, CMP}
|
||||
|
||||
|
||||
def code_semestre_validant(code: str) -> bool:
|
||||
"Vrai si ce CODE entraine la validation du semestre"
|
||||
|
@ -258,6 +258,7 @@ def do_formsemestre_inscription_with_modules(
|
||||
"""Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS
|
||||
(donc sauf le sport)
|
||||
"""
|
||||
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
# inscription au semestre
|
||||
args = {"formsemestre_id": formsemestre_id, "etudid": etudid}
|
||||
if etat is not None:
|
||||
@ -284,7 +285,7 @@ def do_formsemestre_inscription_with_modules(
|
||||
f"do_formsemestre_inscription_with_modules: group {group:r} belongs to non editable partition"
|
||||
)
|
||||
|
||||
# inscription a tous les modules de ce semestre
|
||||
# Inscription à tous les modules de ce semestre
|
||||
modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
|
||||
formsemestre_id=formsemestre_id
|
||||
)
|
||||
@ -294,6 +295,8 @@ def do_formsemestre_inscription_with_modules(
|
||||
{"moduleimpl_id": mod["moduleimpl_id"], "etudid": etudid},
|
||||
formsemestre_id=formsemestre_id,
|
||||
)
|
||||
# Mise à jour des inscriptions aux parcours:
|
||||
formsemestre.update_inscriptions_parcours_from_groups()
|
||||
|
||||
|
||||
def formsemestre_inscription_with_modules_etud(
|
||||
|
@ -149,7 +149,10 @@ def formsemestre_status_menubar(sem):
|
||||
{
|
||||
"title": "Voir la formation %(acronyme)s (v%(version)s)" % F,
|
||||
"endpoint": "notes.ue_table",
|
||||
"args": {"formation_id": sem["formation_id"]},
|
||||
"args": {
|
||||
"formation_id": sem["formation_id"],
|
||||
"semestre_idx": sem["semestre_id"],
|
||||
},
|
||||
"enabled": True,
|
||||
"helpmsg": "Tableau de bord du semestre",
|
||||
},
|
||||
|
@ -29,6 +29,7 @@
|
||||
"""
|
||||
from flask import render_template
|
||||
|
||||
from app.models import Partition
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import sco_groups
|
||||
from app.scodoc.sco_exceptions import AccessDenied
|
||||
@ -39,8 +40,8 @@ def affect_groups(partition_id):
|
||||
Permet aussi la creation et la suppression de groupes.
|
||||
"""
|
||||
# réécrit pour 9.0.47 avec un template
|
||||
partition = sco_groups.get_partition(partition_id)
|
||||
formsemestre_id = partition["formsemestre_id"]
|
||||
partition = Partition.query.get_or_404(partition_id)
|
||||
formsemestre_id = partition.formsemestre_id
|
||||
if not sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
|
||||
raise AccessDenied("vous n'avez pas la permission de modifier les groupes")
|
||||
partition.formsemestre.setup_parcours_groups()
|
||||
@ -53,8 +54,9 @@ def affect_groups(partition_id):
|
||||
),
|
||||
sco_footer=html_sco_header.sco_footer(),
|
||||
partition=partition,
|
||||
partitions_list=sco_groups.get_partitions_list(
|
||||
formsemestre_id, with_default=False
|
||||
# Liste des partitions sans celle par defaut:
|
||||
partitions_list=partition.formsemestre.partitions.filter(
|
||||
Partition.partition_name != None
|
||||
),
|
||||
formsemestre_id=formsemestre_id,
|
||||
)
|
||||
|
@ -1,13 +1,13 @@
|
||||
{# -*- mode: jinja-html -*- #}
|
||||
{{ sco_header|safe }}
|
||||
<h2 class="formsemestre">Affectation aux groupes de {{ partition["partition_name"] }}</h2>
|
||||
<h2 class="formsemestre">Affectation aux groupes de {{ partition.partition_name }}</h2>
|
||||
|
||||
<p>Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne
|
||||
sont enregistrées que lorsque vous cliquez sur le bouton "<em>Enregistrer ces groupes</em>".
|
||||
Vous pouvez créer de nouveaux groupes. Pour <em>supprimer</em> un groupe, utiliser le lien
|
||||
"suppr." en haut à droite de sa boite.
|
||||
Vous pouvez aussi <a class="stdlink"
|
||||
href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, partition_id=partition['partition_id']) }}"
|
||||
href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, partition_id=partition.id) }}"
|
||||
>répartir automatiquement les groupes</a>.
|
||||
</p>
|
||||
|
||||
@ -15,24 +15,24 @@ href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, pa
|
||||
<div id="ginfo"></div>
|
||||
<div id="savedinfo"></div>
|
||||
<form name="formGroup" id="formGroup" onSubmit="return false;">
|
||||
<input type="hidden" name="partition_id" value="{{ partition['partition_id'] }}"/>
|
||||
<input name="groupName" size="6"/>
|
||||
<input type="button" onClick="createGroup();" value="Créer groupe"/>
|
||||
|
||||
<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
|
||||
|
||||
<input type="button"
|
||||
onClick="document.location = '{{ url_for( 'notes.formsemestre_status', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id) }}'"
|
||||
value="Annuler" /> Éditer groupes de
|
||||
<select name="other_partition_id" onchange="GotoAnother();">
|
||||
{% for p in partitions_list %}
|
||||
<option value="{{ p['id'] }}" {{
|
||||
"selected" if p['partition_id'] == partition['partition_id']
|
||||
}}>{{
|
||||
p["partition_name"]
|
||||
}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" name="partition_id" value="{{ partition.id }}"/>
|
||||
<input name="groupName" size="6"/>
|
||||
<input type="button" onClick="createGroup();" value="Créer groupe"/>
|
||||
|
||||
<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
|
||||
|
||||
<input type="button"
|
||||
onClick="document.location = '{{ url_for( 'notes.formsemestre_status', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id) }}'"
|
||||
value="Annuler" /> Éditer groupes de
|
||||
<select name="other_partition_id" onchange="GotoAnother();">
|
||||
{% for p in partitions_list %}
|
||||
<option value="{{ p.id }}" {{
|
||||
"selected" if p.id == partition.id
|
||||
}}>{{
|
||||
p.partition_name
|
||||
}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div id="groups">
|
||||
|
128
migrations/versions/4311cc342dbd_validations_but.py
Normal file
128
migrations/versions/4311cc342dbd_validations_but.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Validations BUT
|
||||
|
||||
Revision ID: 4311cc342dbd
|
||||
Revises: a2771105c21c
|
||||
Create Date: 2022-05-28 16:46:09.861248
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4311cc342dbd"
|
||||
down_revision = "a2771105c21c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"apc_validation_annee",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("etudid", sa.Integer(), nullable=False),
|
||||
sa.Column("ordre", sa.Integer(), nullable=False),
|
||||
sa.Column("formsemestre_id", sa.Integer(), nullable=True),
|
||||
sa.Column("annee_scolaire", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"date",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("code", sa.String(length=16), nullable=False),
|
||||
sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["formsemestre_id"],
|
||||
["notes_formsemestre.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("etudid", "annee_scolaire"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_apc_validation_annee_code"),
|
||||
"apc_validation_annee",
|
||||
["code"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_apc_validation_annee_etudid"),
|
||||
"apc_validation_annee",
|
||||
["etudid"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"apc_validation_rcue",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("etudid", sa.Integer(), nullable=False),
|
||||
sa.Column("formsemestre_id", sa.Integer(), nullable=True),
|
||||
sa.Column("ue1_id", sa.Integer(), nullable=False),
|
||||
sa.Column("ue2_id", sa.Integer(), nullable=False),
|
||||
sa.Column("parcours_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"date",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("code", sa.String(length=16), nullable=False),
|
||||
sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["formsemestre_id"],
|
||||
["notes_formsemestre.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parcours_id"],
|
||||
["apc_parcours.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["ue1_id"],
|
||||
["notes_ue.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["ue2_id"],
|
||||
["notes_ue.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("etudid", "formsemestre_id", "ue1_id", "ue2_id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_apc_validation_rcue_code"),
|
||||
"apc_validation_rcue",
|
||||
["code"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_apc_validation_rcue_etudid"),
|
||||
"apc_validation_rcue",
|
||||
["etudid"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_apc_validation_rcue_formsemestre_id"),
|
||||
"apc_validation_rcue",
|
||||
["formsemestre_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
op.f("ix_apc_validation_rcue_formsemestre_id"), table_name="apc_validation_rcue"
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_apc_validation_rcue_etudid"), table_name="apc_validation_rcue"
|
||||
)
|
||||
op.drop_index(op.f("ix_apc_validation_rcue_code"), table_name="apc_validation_rcue")
|
||||
op.drop_table("apc_validation_rcue")
|
||||
op.drop_index(
|
||||
op.f("ix_apc_validation_annee_etudid"), table_name="apc_validation_annee"
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_apc_validation_annee_code"), table_name="apc_validation_annee"
|
||||
)
|
||||
op.drop_table("apc_validation_annee")
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in New Issue
Block a user