diff --git a/app/org_eleicoes/votepeloclima/candidature/fields.py b/app/org_eleicoes/votepeloclima/candidature/fields.py index 82171df7..046623b3 100644 --- a/app/org_eleicoes/votepeloclima/candidature/fields.py +++ b/app/org_eleicoes/votepeloclima/candidature/fields.py @@ -388,9 +388,9 @@ def get_bound_field(self, form, field_name): class ToogleButtonInput(forms.CheckboxInput): template_name = "forms/widgets/toggle_button.html" - @property - def media(self): - return forms.Media(css={"screen": ["css/icons.css"]}) + # @property + # def media(self): + # return forms.Media(css={"screen": ["css/icons.css"]}) def __init__(self, text_html, icon_name=None, *args, **kwargs): self.text_html = text_html @@ -469,3 +469,13 @@ def get_bound_field(self, form, field_name): bound_field.label = mark_safe(bound_field.label) return bound_field + + +class ButtonCheckboxSelectMultiple(forms.CheckboxSelectMultiple): + template_name = "forms/widgets/button_input_select.html" + option_template_name = "forms/widgets/button_input_option.html" + + +class ButtonRadioSelect(forms.RadioSelect): + template_name = "forms/widgets/button_input_select.html" + option_template_name = "forms/widgets/button_input_option.html" \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/forms.py b/app/org_eleicoes/votepeloclima/candidature/forms.py index 7c3c6c93..93a0dbc9 100644 --- a/app/org_eleicoes/votepeloclima/candidature/forms.py +++ b/app/org_eleicoes/votepeloclima/candidature/forms.py @@ -600,4 +600,4 @@ class Meta: ("sobre-sua-trajetoria", TrackForm), ("complemente-seu-perfil", ProfileForm), ("checkout", CheckoutForm), -] +] \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/forms/__init__.py b/app/org_eleicoes/votepeloclima/candidature/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/org_eleicoes/votepeloclima/candidature/forms/filters.py b/app/org_eleicoes/votepeloclima/candidature/forms/filters.py new file mode 100644 index 00000000..28f49aa7 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/forms/filters.py @@ -0,0 +1,140 @@ +from django import forms +from django.utils.functional import lazy + +from crispy_forms.layout import Layout, Div +from crispy_forms.helper import FormHelper +# from django_select2.forms import Select2Widget + +from ..layout import NoCrispyField +from ..choices import Gender, Color +from ..fields import CepField, ButtonCheckboxSelectMultiple, ButtonRadioSelect +from ..locations_utils import get_ufs, get_choices +from ..models import Candidature + +from .register import ProposeForm + + +class RemoveRequiredMixin: + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.disable_csrf = True + + for field_name in self.fields: + self.fields[field_name].required = False + + +class FilterFormHeader(RemoveRequiredMixin, forms.ModelForm): + keyword = forms.CharField( + label="Buscar por temas ou nomes", + widget=forms.TextInput(attrs={"placeholder": "Digite um tema ou nome"}), + ) + state = CepField( + field="state", + label="Estado", + placeholder="Todos os estados", + choices=[("", "Todos os estados")] + lazy(get_ufs, list)(), + ) + city = CepField( + field="city", parent="state", label="Município", placeholder="Selecione" + ) + + class Meta: + model = Candidature + fields = [ + "state", + "city", + "intended_position", + "political_party", + "keyword", + ] + widgets = { + # "political_party": Select2Widget() + } + + class Media: + js = ["https://code.jquery.com/jquery-3.5.1.min.js"] + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + state = self.data.get("state") + if state: + self.fields["city"].choices = [("", "Selecione")] + get_choices(state) + + +class FilterFormSidebar(RemoveRequiredMixin, forms.ModelForm): + proposes = forms.MultipleChoiceField( + label="Propostas", widget=ButtonCheckboxSelectMultiple + ) + mandate_type = forms.ChoiceField( + label="Tipo de mandato", + choices=(("", "Todos"), ("individual", "Individual"), ("coletivo", "Mandato coletivo")), + widget=ButtonRadioSelect, + ) + gender = forms.MultipleChoiceField( + label="Gênero", choices=Gender.choices[1:], widget=ButtonCheckboxSelectMultiple + ) + color = forms.MultipleChoiceField( + label="Raça", choices=Color.choices[1:], widget=ButtonCheckboxSelectMultiple + ) + + class Meta: + model = Candidature + fields = ["proposes", "mandate_type", "gender", "color"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["proposes"].choices = self.get_proposes_choices() + + self.helper.layout = Layout( + NoCrispyField("proposes"), + NoCrispyField("mandate_type"), + NoCrispyField("gender"), + NoCrispyField("color"), + ) + + def get_proposes_choices(self): + form = ProposeForm() + choices = [] + for field_name in form.fields: + if field_name != "properties": + choices.append((field_name, form.fields[field_name].checkbox_label)) + + return choices + + +class FilterFactoryForm(object): + + def __init__(self, data=None): + self._errors = {} + self.data = data + + self.header = FilterFormHeader(self.data) + self.sidebar = FilterFormSidebar(self.data) + + def is_valid(self): + is_valid = True + + if not self.header.is_valid(): + is_valid = False + self._errors.update(self.header.errors) + + if not self.sidebar.is_valid(): + is_valid = False + self._errors.update(self.sidebar.errors) + + return is_valid + + @property + def errors(self): + return self._errors if len(self._errors) > 0 else None + + @property + def cleaned_data(self): + return {**self.header.cleaned_data, **self.sidebar.cleaned_data} diff --git a/app/org_eleicoes/votepeloclima/candidature/forms/register.py b/app/org_eleicoes/votepeloclima/candidature/forms/register.py new file mode 100644 index 00000000..0a113949 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/forms/register.py @@ -0,0 +1,603 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.functional import lazy +from django.templatetags.static import static + +from captcha.widgets import ReCaptchaV2Checkbox +from entangled.forms import EntangledModelFormMixin +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Div, Field, HTML +from bootstrap_datepicker_plus.widgets import DatePickerInput + +from ..locations_utils import get_ufs, get_choices +from ..choices import ( + PoliticalParty, + IntendedPosition, + Education, + Color, + Gender, + Sexuality, +) +from ..models import CandidatureFlow +from ..fields import ( + ValidateOnceReCaptchaField, + CheckboxTextField, + InlineArrayField, + CepField, + ToggleButtonField, + VideoField, + InputMask, + HTMLBooleanField, +) +from ..layout import NoCrispyField, FileField + + +class DisabledMixin: + + def __init__(self, disabled=False, *args, **kwargs): + super().__init__(*args, **kwargs) + + if disabled: + self.disabled = disabled + for field_name in self.fields: + self.fields[field_name].widget.attrs.update( + {"readonly": True, "disabled": True} + ) + + +class CaptchaForm(EntangledModelFormMixin, forms.ModelForm): + captcha = ValidateOnceReCaptchaField(widget=ReCaptchaV2Checkbox()) + + class Meta: + # Sobrescreve o template na view para criar uma página customizada + model = CandidatureFlow + entangled_fields = {"properties": ["captcha"]} + untangled_fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["captcha"].label = "" + + +class AppointmentForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + appointment_1 = ToggleButtonField( + icon_name="ds-icon-compromisso-1", + text_html="Políticas de adaptação das cidades para reduzir tragédias", + ) + appointment_2 = ToggleButtonField( + icon_name="ds-icon-compromisso-2", + text_html="Políticas para redução de emissões e transição energética", + ) + appointment_3 = ToggleButtonField( + icon_name="ds-icon-compromisso-3", + text_html="Políticas sociais de apoio às populações atingidas", + ) + appointment_4 = ToggleButtonField( + icon_name="ds-icon-compromisso-4", + text_html="Transição climática com justiça social, racial e de gênero", + ) + appointment_5 = ToggleButtonField( + icon_name="ds-icon-compromisso-5", + text_html="Proteção ambiental e de recursos naturais", + ) + appointment_6 = ToggleButtonField( + icon_name="ds-icon-compromisso-6", + text_html="Incentivo à participação popular e ao engajamento da juventude", + ) + appointment_7 = ToggleButtonField( + icon_name="ds-icon-compromisso-7", + text_html="Investimentos em pesquisa e inovação para enfrentar a crise climática", + ) + appointment_8 = ToggleButtonField( + icon_name="ds-icon-compromisso-8", + text_html="Valorização de saberes tradicionais e tecnologias sociais na busca de soluções", + ) + + class Meta: + title = "Você assume compromisso com..." + description = "Esses são os valores e princípios básicos que todos os candidatos devem assumir ao criar um perfil no Vote pelo Clima. Eles representam compromissos essenciais e serão visíveis aos eleitores, evidenciando sua dedicação a um futuro sustentável. Para continuar, é necessário selecionar todos os compromissos." + model = CandidatureFlow + entangled_fields = { + "properties": [ + "appointment_1", + "appointment_2", + "appointment_3", + "appointment_4", + "appointment_5", + "appointment_6", + "appointment_7", + "appointment_8", + ] + } + untangled_fields = [] + + +class PersonalForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + legal_name = forms.CharField( + label="Nome", + widget=forms.TextInput(attrs={"placeholder": "Digite seu nome completo"}), + ) + email = forms.EmailField( + label="E-mail", + widget=forms.EmailInput(attrs={"placeholder": "Digite seu e-mail"}), + ) + cpf = forms.CharField( + label="CPF", + help_text="CPF é necessário para confirmar sua identidade junto ao TSE", + widget=InputMask( + mask="000.000.000-00", attrs={"placeholder": "Digite seu CPF"} + ), + ) + birth_date = forms.DateField( + label="Data de nascimento", + widget=DatePickerInput( + attrs={"placeholder": "dd/mm/yyyy"}, + options={"locale": "pt-BR", "format": "DD/MM/YYYY"}, + ), + localize="pt-BR", + ) + + class Meta: + title = "Informações pessoais" + description = "Vamos lá! Essas informações são essenciais para verificar sua candidatura e garantir a segurança. Se for uma candidatura coletiva, a pessoa responsável deve ter os dados registrados no TSE." + model = CandidatureFlow + entangled_fields = { + "properties": ["legal_name", "email", "cpf", "birth_date"] + } + untangled_fields = [] + + class Media: + js = ["https://code.jquery.com/jquery-3.5.1.min.js"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + Div( + Div(Field("legal_name"), css_class="g-col-12 g-col-md-6"), + Div(Field("email"), css_class="g-col-12 g-col-md-6"), + Div(Field("cpf"), css_class="g-col-12 g-col-md-6"), + Div(Field("birth_date"), css_class="g-col-12 g-col-md-6"), + css_class="grid", + style="grid-row-gap:0;", + ) + ) + + +class ApplicationForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + ballot_name = forms.CharField( + label="Nome na urna", + help_text="Nome público, registrado no TSE.", + widget=forms.TextInput( + attrs={"placeholder": "Digite o nome que aparecerá na urna"} + ), + ) + number_id = forms.IntegerField( + label="Número na urna", + min_value=1, + help_text="Número da candidatura", + widget=forms.NumberInput( + attrs={"placeholder": "Digite seu número de identificação"} + ), + ) + intended_position = forms.ChoiceField( + label="Cargo pretendido", + choices=IntendedPosition.choices, + help_text="Selecione o cargo que você está concorrendo", + ) + state = CepField( + field="state", + label="Estado", + placeholder="Selecione", + choices=[("", "Selecione")] + lazy(get_ufs, list)(), + help_text="Estado onde você está concorrendo", + ) + city = CepField( + field="city", + parent="state", + label="Cidade", + placeholder="Selecione", + help_text="Cidade onde você está concorrendo", + ) + is_collective_mandate = forms.BooleanField( + label="É um mandato coletivo?", + required=False, + initial=False, + widget=forms.RadioSelect(choices=((True, "Sim"), (False, "Não"))), + ) + political_party = forms.ChoiceField( + label="Partido político", choices=PoliticalParty.choices + ) + deputy_mayor = forms.CharField( + label="Nome vice-prefeitura", + widget=forms.TextInput(attrs={"placeholder": "Digite o nome"}), + required=False, + ) + deputy_mayor_political_party = forms.ChoiceField( + label="Partido político", choices=PoliticalParty.choices, required=False + ) + + class Meta: + title = "Dados de candidatura" + description = "Preencha os detalhes sobre sua candidatura." + model = CandidatureFlow + entangled_fields = { + "properties": [ + "ballot_name", + "number_id", + "intended_position", + "state", + "city", + "is_collective_mandate", + "political_party", + "deputy_mayor", + "deputy_mayor_political_party", + ] + } + untangled_fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + # TODO: investigar porque quando usamos layout o select2 duplica o campo + self.helper.layout = Layout( + Div( + Div(Field("ballot_name"), css_class="g-col-12 g-col-md-6"), + Div(Field("number_id"), css_class="g-col-12 g-col-md-6"), + Div(Field("intended_position"), css_class="g-col-12 g-col-md-6"), + Div(Field("political_party"), css_class="g-col-12 g-col-md-6"), + Div(Field("state"), css_class="g-col-12 g-col-md-6"), + Div(Field("city"), css_class="g-col-12 g-col-md-6"), + Div( + NoCrispyField("is_collective_mandate"), + css_class="g-col-12 g-col-md-6 mb-3", + ), + Div( + HTML( + """ +
+
Informações sobre vice-prefeitura
+

Adicione informações somente em caso de candidaturas para prefeitura

+ """ + ), + css_class="g-col-12 g-col-md-12", + ), + Div(Field("deputy_mayor"), css_class="g-col-12 g-col-md-6"), + Div( + Field("deputy_mayor_political_party"), + css_class="g-col-12 g-col-md-6", + ), + css_class="grid", + style="grid-row-gap:0;", + ) + ) + data = kwargs.get("data", None) + instance = kwargs.get("instance", None) + + if data or instance: + state = None + if instance: + state = instance.properties.get("state", None) + if data: + state = data.get("informacoes-de-candidatura-state", None) + + if state: + self.fields["city"].choices = get_choices(state) + + def clean(self): + cleaned_data = super().clean() + intended_position = cleaned_data.get("intended_position") + deputy_mayor = cleaned_data.get("deputy_mayor") + deputy_mayor_political_party = cleaned_data.get("deputy_mayor_political_party") + + if intended_position == IntendedPosition.prefeitura and not deputy_mayor: + self.add_error("deputy_mayor", "Esse campo é obrigatório") + + if ( + intended_position == IntendedPosition.prefeitura + and not deputy_mayor_political_party + ): + self.add_error("deputy_mayor_political_party", "Esse campo é obrigatório") + + +propose_text_label = "Proposta" +propose_text_help_text = ( + "Descreva brevemente sua proposta. Até 600 caracteres." +) + + +class ProposeForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + transporte_e_mobilidade = CheckboxTextField( + checkbox_label="Transporte e Mobilidade", + help_text="Transporte coletivo gratuito e de qualidade, modais com menos emissões e mobilidade ativa.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + ) + gestao_de_residuos = CheckboxTextField( + checkbox_label="Gestão de Resíduos", + help_text="Compostagem de resíduos orgânicos, economia circular, mais iniciativas de catadores e catadoras de materiais recicláveis, uso de materiais biodegradáveis.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + povos_originarios_tradicionais = CheckboxTextField( + checkbox_label="Povos e comunidades tradicionais", + help_text="Direitos, reconhecimento e valorização de conhecimentos e tecnologias de povos e comunidades tradicionais.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + educacao_climatica = CheckboxTextField( + checkbox_label="Educação Climática", + help_text="Ensino sobre meio ambiente e mudanças climáticas nas escolas, formação profissional para empregos verdes, formação de agentes populares para gestão do risco climático.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + combate_racismo_ambiental = CheckboxTextField( + checkbox_label="Enfrentamento ao racismo ambiental", + help_text="Cultura viva, segurança cidadã, participação ativa das comunidades, protagonismo de pessoas negras, indígenas e jovens na construção de soluções.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + moradia_digna = CheckboxTextField( + checkbox_label="Moradia Digna", + help_text="Políticas habitacionais justas e participativas, moradia resiliente aos impactos de eventos climáticos extremos, eficiência hídrica e energética.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + transicao_energetica = CheckboxTextField( + checkbox_label="Transição energética justa", + help_text="Mais fontes de energias renováveis que substituam gradualmente o uso de fontes poluidoras e garantam os direitos socioambientais das populações em seus territórios.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + agricultura_sustentavel = CheckboxTextField( + checkbox_label="Alimentos saudáveis", + help_text="Agricultura livre de agrotóxicos, produção agroecológica, agricultura familiar e mais vegetais na mesa da população.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + direito_a_cidade = CheckboxTextField( + checkbox_label="Direito à Cidade", + help_text="Mais áreas verdes e parques públicos, menos ilhas de calor, segurança pública e bem-estar urbano, cidades mais sustentáveis e inclusivas.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + adaptacao_reducao_desastres = CheckboxTextField( + checkbox_label="Adaptação e redução de desastres", + help_text="Políticas públicas para pessoas atingidas por eventos climáticos extremos, projetos e recursos para infraestrutura resiliente, monitoramento e resposta rápida a desastres ambientais.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + direito_dos_animais = CheckboxTextField( + checkbox_label="Proteção de animais", + help_text="Habitats da fauna local protegidos, controle de zoonoses para evitar doenças vetoriais agravadas pela crise climática.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + economia_verde = CheckboxTextField( + checkbox_label="Economia Verde", + help_text="Indústrias e processos produtivos sem carbono, bioeconomia, novos empregos verdes.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + pessoas_afetadas_desastres = CheckboxTextField( + checkbox_label="Pessoas afetadas por desastres", + help_text="Políticas públicas de recuperação ambiental e assistência imediata, incluindo moradias sustentáveis e serviços de saúde física e mental, para comunidades impactadas por desastres ambientais.", + text_label=propose_text_label, + text_help_text=propose_text_help_text, + required=False, + max_length=600 + + ) + + + class Meta: + title = "Suas propostas" + model = CandidatureFlow + entangled_fields = { + "properties": [ + "transporte_e_mobilidade", + "gestao_de_residuos", + "povos_originarios_tradicionais", + "educacao_climatica", + "combate_racismo_ambiental", + "moradia_digna", + "transicao_energetica", + "agricultura_sustentavel", + "direito_a_cidade", + "adaptacao_reducao_desastres", + "direito_dos_animais", + "economia_verde", + "pessoas_afetadas_desastres" + ] + } + untangled_fields = [] + + def clean(self): + cleaned_data = super().clean() + selected_size = len(list(filter(lambda x: bool(x), cleaned_data.values()))) + if selected_size > 3: + raise ValidationError("Selecione apenas 3 bandeiras") + elif selected_size == 0: + raise ValidationError("Selecione ao menos 1 bandeira") + return cleaned_data + + +class TrackForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + education = forms.ChoiceField( + label="Escolaridade", required=False, choices=Education.choices + ) + employment = forms.CharField(label="Ocupação", required=False) + short_description = forms.CharField( + label="Minibio", + widget=forms.Textarea(attrs={"placeholder": "Escreva uma breve biografia"}), + help_text="Fale um pouco sobre você e sua jornada até aqui. Até 500 caracteres.", + max_length=500 + ) + milestones = InlineArrayField( + forms.CharField(max_length=140, required=False), + required=False, + label="Histórico de atuação", + item_label="Realização", + add_button_text="Adicionar outra", + help_text="Adicione momentos e realizações marcantes da sua trajetória. Até 150 caracteres.", + placeholder="Recebi o Prêmio XYZ pela Iniciativa Ambiental", + ) + + def clean_milestones(self): + value = self.cleaned_data["milestones"] + return value + + class Meta: + title = "Trajetória" + description = "Compartilhe um pouco sobre sua trajetória. Essas informações ajudarão os eleitores a conhecerem melhor sua história e seu compromisso com a causa." + model = CandidatureFlow + entangled_fields = { + "properties": [ + "education", + "employment", + "short_description", + "milestones", + ] + } + untangled_fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + Div( + Div(Field("education"), css_class="g-col-12 g-col-md-6"), + Div(Field("employment"), css_class="g-col-12 g-col-md-6"), + Div(Field("short_description"), css_class="g-col-12"), + Div( + HTML("""


"""), + css_class="g-col-12 g-col-md-12", + ), + Div(Field("milestones"), css_class="g-col-12"), + css_class="grid", + style="grid-row-gap:0;", + ) + ) + + +class ProfileForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + video = VideoField(label="Vídeo", required=False, help_text="Tamanho máximo 50mb.") + photo = forms.ImageField(label="Foto") + gender = forms.ChoiceField(label="Gênero", choices=Gender.choices) + color = forms.ChoiceField(label="Raça", choices=Color.choices) + sexuality = forms.ChoiceField( + label="Sexualidade", required=False, choices=Sexuality.choices + ) + social_media = InlineArrayField( + forms.URLField(required=False), + required=False, + label="Redes sociais", + item_label="Rede social", + add_button_text="Adicionar outra", + help_text="Conecte suas redes sociais para ampliar sua visibilidade e engajamento com os eleitores.", + ) + + def clean_social_media(self): + value = self.cleaned_data["social_media"] + return value + + class Meta: + title = "Complemente seu perfil" + description = "Adicione mais detalhes ao seu perfil para torná-lo completo e atrativo aos eleitores. Essas informações ajudarão a construir uma apresentação mais detalhada e engajadora." + model = CandidatureFlow + entangled_fields = { + "properties": [ + "gender", + "color", + "sexuality", + "social_media", + ] + } + untangled_fields = [ + "video", + "photo", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + Div( + Div(FileField("photo"), css_class="g-col-12 g-col-md-6"), + Div(FileField("video"), css_class="g-col-12 g-col-md-6"), + Div(Field("gender"), css_class="g-col-12 g-col-md-6"), + Div(Field("color"), css_class="g-col-12 g-col-md-6"), + Div(Field("sexuality"), css_class="g-col-12 g-col-md-6"), + Div( + HTML("""
"""), + css_class="g-col-12 g-col-md-12", + ), + Div(Field("social_media"), css_class="g-col-12"), + css_class="grid", + style="grid-row-gap:0;", + ) + ) + + +class CheckoutForm(EntangledModelFormMixin, DisabledMixin, forms.ModelForm): + is_valid = HTMLBooleanField( + label=f'Ao preencher o formulário e se cadastrar na Campanha, você está ciente de que seus dados pessoais serão tratados de acordo com o Aviso de Privacidade.' + ) + + class Meta: + title = "Confirmar informações" + model = CandidatureFlow + entangled_fields = {"properties": ["is_valid"]} + untangled_fields = [] + + +register_form_list = [ + ("captcha", CaptchaForm), + ("compromissos", AppointmentForm), + ("informacoes-pessoais", PersonalForm), + ("informacoes-de-candidatura", ApplicationForm), + ("suas-propostas", ProposeForm), + ("sobre-sua-trajetoria", TrackForm), + ("complemente-seu-perfil", ProfileForm), + ("checkout", CheckoutForm), +] \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/models.py b/app/org_eleicoes/votepeloclima/candidature/models.py index 598e7854..f23e2bc6 100644 --- a/app/org_eleicoes/votepeloclima/candidature/models.py +++ b/app/org_eleicoes/votepeloclima/candidature/models.py @@ -11,7 +11,6 @@ # Acompanhar validação da candidatura # Armazenar e acompanhar etapas do preenchimento das informações - class Candidature(models.Model): legal_name = models.CharField(max_length=150, verbose_name="Nome") ballot_name = models.CharField(max_length=100, verbose_name="Nome na Urna") @@ -28,8 +27,8 @@ class Candidature(models.Model): political_party = models.CharField(max_length=60, choices=PoliticalParty.choices, verbose_name="Partido Político") video = models.FileField(upload_to="candidatures/videos/", null=True, blank=True, verbose_name="Vídeo") photo = models.FileField(upload_to="candidatures/photos/", null=True, blank=True, verbose_name="Foto") - gender = models.CharField(max_length=30, choices=Gender.choices, verbose_name="Gênero") - color = models.CharField(max_length=30, choices=Color.choices, verbose_name="Raça") + gender = models.CharField(max_length=30, verbose_name="Gênero") + color = models.CharField(max_length=30, verbose_name="Raça") sexuality = models.CharField(max_length=30, null=True, blank=True, choices=Sexuality.choices, verbose_name="Sexualidade") social_media = models.JSONField(blank=True, null=True, default=list, verbose_name="Redes Sociais") education = models.CharField(max_length=50, null=True, blank=True, choices=Education.choices, verbose_name="Educação") @@ -61,6 +60,27 @@ def get_state_display(self): def get_city_display(self): cities = dict(get_choices(self.state)) return cities.get(self.city, "") + + @property + def get_color_display(self): + return dict(Color.choices).get(self.color) + + @property + def get_gender_display(self): + return dict(Gender.choices).get(self.gender) + + @property + def get_proposes_display(self): + from org_eleicoes.votepeloclima.candidature.forms.register import ProposeForm + + form = ProposeForm() + proposes = [] + for field_name, value in self.proposes.items(): + if value: + proposes.append(form[field_name].checkbox_label) + + return proposes + def save(self, *args, **kwargs): if not self.slug: diff --git a/app/org_eleicoes/votepeloclima/candidature/static/scss/candidaturesearch.scss b/app/org_eleicoes/votepeloclima/candidature/static/scss/candidaturesearch.scss new file mode 100644 index 00000000..ed940671 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/static/scss/candidaturesearch.scss @@ -0,0 +1,148 @@ +legend.form-label { + color: var(--bs-primary); + text-transform: uppercase; + font-weight: 600; + font-size: 16px; +} + +.search-header { + // d-flex flex-column flex-md-row align-items-md-center + display: flex; + flex-direction: column; + gap: 0; + + .btn { + display: flex; + align-items: center; + padding: 8px 10px; + } + + @media (min-width: 768px) { + align-items: center; + flex-direction: row; + gap: 1rem; + } +} + +.form-check-input:checked { + background-color: var(--bs-primary); +} + +.btn-check+.btn { + padding: 8px 12px; + font-size: 14px; + + &.btn-outline-dark { + // background-color: var(--bs-gray-100); + --bs-btn-active-bg: var(--bs-primary); + --bs-btn-active-border-color: var(--bs-primary); + --bs-btn-hover-color: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); + + --bs-btn-border-color: var(--bs-gray-300); + --bs-btn-bg: rgba(255,255,255,.5); + } +} + +.btn-check:not(:checked)+.btn { + &.btn-outline-dark { + &:hover { + color: var(--bs-btn-hover-color); + background-color: var(--bs-btn-bg); + border-color: var(--bs-btn-hover-border-color); + } + } +} + +.empty-box { + background: rgba(255,255,255,.15); + border-color: var(--bs-gray-300); + border-radius: 20px; +} + +.form-control, .form-select, .select2-container--default .select2-selection--single .select2-selection__rendered { + font-size: 14px; +} + +// Card +.card { + --bs-card-border-radius: 12px; + --bs-border-radius: 12px; + + text-decoration: none; + background-color: rgba(255,255,255, .15); + cursor: pointer; + + h6 { + font-size: 14px; + margin-bottom: 5px; + } + + .form-text { + font-size: 12px; + height: 54px; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 5px; + } + + .card-img-top { + height: 235px; + object-fit: cover; + object-position: top; + } + + .card-footer { + display: flex; + padding: 0; + } + + .card-footer .btn { + flex-grow: 1; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:hover { + border-color: var(--bs-secondary); + color: var(--bs-secondary); + background-color: rgba(255,255,255, .40); + + .btn { + background-color: var(--bs-secondary); + border-color: var(--bs-secondary); + } + + hr { + color: var(--bs-secondary); + } + } +} + +// Hide Inputs +@media (max-width: 768px) { + + #div_id_intended_position, #div_id_political_party { + display: none; + } + + .search-sidebar { + label { + cursor: pointer; + } + } + +} + + +.search-sidebar { + #sidebar-toggle { + display: none; + + &:checked + label { + i.ds-sidebar-arrow { + rotate: 180deg; + } + } + } +} \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/templates/candidature/candidature_search.html b/app/org_eleicoes/votepeloclima/candidature/templates/candidature/candidature_search.html new file mode 100644 index 00000000..856696a5 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/templates/candidature/candidature_search.html @@ -0,0 +1,104 @@ +{% extends "votepeloclima/base.html" %} +{% load static crispy_forms_filters compress %} + +{% block head_css %} +{{ block.super }} +{% compress css %} + +{% endcompress %} +{% endblock %} + +{% block content %} +
+
+
+

Conheça candidaturas da sua cidade

+

Use os filtros para descobrir candidatos comprometidos com políticas climáticas! +

+
+ +
+
+ {% crispy form.header %} + +
+
+ +
+ +
+ + +
+
+{% endblock %} + +{% block footer_js %} + +{% endblock %} diff --git a/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_option.html b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_option.html new file mode 100644 index 00000000..0db4cb2f --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_option.html @@ -0,0 +1,2 @@ + +{% if widget.wrap_label %}{% endif %} \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_select.html b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_select.html new file mode 100644 index 00000000..bc9bf512 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/button_input_select.html @@ -0,0 +1,5 @@ +{% with id=widget.attrs.id %}{% for group, options, index in widget.optgroups %}{% if group %} +
{% endif %}{% for option in options %}
+ {% include option.template_name with widget=option %}
{% endfor %}{% if group %} +
{% endif %}{% endfor %} +{% endwith %} \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/checkbox_select_multiple.html b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/checkbox_select_multiple.html new file mode 100644 index 00000000..a6ad9d5b --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/templates/forms/widgets/checkbox_select_multiple.html @@ -0,0 +1,13 @@ +{% for group, options, index in widget.optgroups %} + {% for option in options %} +
+ + +
+ {% endfor %} +{% endfor %} \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/candidature/views/__init__.py b/app/org_eleicoes/votepeloclima/candidature/views/__init__.py index 690520ba..cbc9c7ec 100644 --- a/app/org_eleicoes/votepeloclima/candidature/views/__init__.py +++ b/app/org_eleicoes/votepeloclima/candidature/views/__init__.py @@ -1,27 +1,12 @@ from django.shortcuts import get_object_or_404, render -from django.http import JsonResponse from django.views import View -from formtools.wizard.views import NamedUrlSessionWizardView - from ..models import CandidatureFlowStatus, Candidature -from ..forms import ProposeForm -from ..locations_utils import get_choices - - -class AddressView(View): - def get(self, request, *args, **kwargs): - state = request.GET.get("state") - cities = get_choices(state) - - return JsonResponse([{'code': code, 'name': name} for code, name in cities], safe=False) +from ..forms.register import ProposeForm -class PublicCandidatureView(View): - template_name = "candidature/candidate_profile.html" - - def get(self, request, slug): - candidature = get_object_or_404(Candidature, slug=slug) +class ProposesMixin: + def get_proposes(self, candidature): proposes_list = [] for field_name, value in candidature.proposes.items(): @@ -31,13 +16,21 @@ def get(self, request, slug): "description": value }) + return proposes_list + + +class PublicCandidatureView(View, ProposesMixin): + template_name = "candidature/candidate_profile.html" + + def get(self, request, slug): + candidature = get_object_or_404(Candidature, slug=slug) context = { "candidature": candidature, - "proposes": proposes_list, + "proposes": self.get_proposes(candidature), } # Verifica se a candidatura está aprovada if candidature.status() != CandidatureFlowStatus.is_valid.label: return render(request, 'candidature/not_approved.html', context) - - return render(request, self.template_name, context) \ No newline at end of file + + return render(request, self.template_name, context) diff --git a/app/org_eleicoes/votepeloclima/candidature/views/base.py b/app/org_eleicoes/votepeloclima/candidature/views/base.py index e4479c25..0381be21 100644 --- a/app/org_eleicoes/votepeloclima/candidature/views/base.py +++ b/app/org_eleicoes/votepeloclima/candidature/views/base.py @@ -11,7 +11,7 @@ from ..choices import CandidatureFlowStatus from ..models import CandidatureFlow -from ..forms import register_form_list +from ..forms.register import register_form_list def files_is_equal(file1, file2): diff --git a/app/org_eleicoes/votepeloclima/candidature/views/filters.py b/app/org_eleicoes/votepeloclima/candidature/views/filters.py new file mode 100644 index 00000000..b485542e --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/views/filters.py @@ -0,0 +1,87 @@ +from django.db.models import Q +from django.views.generic import ListView + +from ..models import CandidatureFlowStatus, Candidature +from ..forms.filters import FilterFactoryForm + + +class CandidatureSearchView(ListView): + model = Candidature + template_name = "candidature/candidature_search.html" + context_object_name = "candidatures" + + search_filter_fields = [ + "legal_name", + "ballot_name", + "proposes", + "milestones", + "short_description", + ] + unique_filter_fields = ["political_party", "state", "city", "intended_position"] + multiple_filter_fields = ["gender", "color", "proposes"] + + def get_queryset(self): + queryset = super().get_queryset() + + # Retorna apenas Candidaturas que já tiveram status valido em algum momento do preenchimento + queryset = queryset.filter( + candidatureflow__status__in=[ + CandidatureFlowStatus.is_valid, + CandidatureFlowStatus.editing, + ] + ) + + # Filtra por valores selecionado pelo usuário + form = FilterFactoryForm(data=self.request.GET or None) + + if form.is_valid(): + cleaned_data = form.cleaned_data + + # Filtros AND + for field_name in self.unique_filter_fields: + value = cleaned_data.get(field_name) + if value: + queryset = queryset.filter(**{field_name: value}) + + # Filters OR with Text search ICONTAINS + query = Q() + for field_name in self.search_filter_fields: + value = cleaned_data.get("keyword") + if value: + query |= Q(**{f"{field_name}__icontains": value}) + + queryset = queryset.filter(query) + + self.multiple_filter_fields + + # Filters OR with Multiple Choice + for field_name in self.multiple_filter_fields: + query = Q() + values = cleaned_data.get(field_name) + for value in values: + if field_name == "proposes": + query |= ~Q(**{f"{field_name}__{value}__exact": ""}) + else: + query |= Q(**{f"{field_name}__exact": value}) + + # Filter is concatenate with AND by field multiple value + queryset = queryset.filter(query) + + # Filtra apenas mandato coletivo + mandate_type = self.request.GET.get("mandate_type") + if mandate_type: + queryset = queryset.filter( + is_collective_mandate=( + True if mandate_type == "coletivo" else False + ) + ) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + form = FilterFactoryForm(data=self.request.GET or None) + context.update({"form": form}) + + return context diff --git a/app/org_eleicoes/votepeloclima/candidature/views/oauth.py b/app/org_eleicoes/votepeloclima/candidature/views/oauth.py index e2211ce5..273cb66b 100644 --- a/app/org_eleicoes/votepeloclima/candidature/views/oauth.py +++ b/app/org_eleicoes/votepeloclima/candidature/views/oauth.py @@ -18,7 +18,7 @@ from ..choices import CandidatureFlowStatus from ..models import CandidatureFlow, Candidature -from ..forms import register_form_list, ProposeForm, AppointmentForm +from ..forms.register import register_form_list, ProposeForm, AppointmentForm disable_edit_steps = [ "informacoes-pessoais", diff --git a/app/org_eleicoes/votepeloclima/candidature/views/public.py b/app/org_eleicoes/votepeloclima/candidature/views/public.py new file mode 100644 index 00000000..3ba2fe42 --- /dev/null +++ b/app/org_eleicoes/votepeloclima/candidature/views/public.py @@ -0,0 +1,12 @@ +from django.views import View +from django.http import JsonResponse + +from ..locations_utils import get_choices + + +class AddressView(View): + + def get(self, request, *args, **kwargs): + state = request.GET.get("state") + cities = get_choices(state) + return JsonResponse([{'code': code, 'name': name} for code, name in cities], safe=False) \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/static/scss/icons.scss b/app/org_eleicoes/votepeloclima/static/scss/icons.scss index b312c212..60d54943 100644 --- a/app/org_eleicoes/votepeloclima/static/scss/icons.scss +++ b/app/org_eleicoes/votepeloclima/static/scss/icons.scss @@ -293,4 +293,40 @@ background-repeat: no-repeat; display: inline-block; background-image: url('data:image/svg+xml,'); +} + +.ds-icon-search { + width: 22px; + height: 22px; + background-size: contain; + background-repeat: no-repeat; + display: inline-block; + background-image: url('data:image/svg+xml,'); +} + +.ds-icon-search-green { + width: 32px; + height: 33px; + background-size: contain; + background-repeat: no-repeat; + display: inline-block; + background-image: url('data:image/svg+xml,'); +} + +.ds-sidebar-bars { + width: 16px; + height: 17px; + background-size: contain; + background-repeat: no-repeat; + display: inline-block; + background-image: url('data:image/svg+xml,'); +} + +.ds-sidebar-arrow { + width: 16px; + height: 17px; + background-size: contain; + background-repeat: no-repeat; + display: inline-block; + background-image: url('data:image/svg+xml,'); } \ No newline at end of file diff --git a/app/org_eleicoes/votepeloclima/urls.py b/app/org_eleicoes/votepeloclima/urls.py index 5ff0882c..0e188452 100644 --- a/app/org_eleicoes/votepeloclima/urls.py +++ b/app/org_eleicoes/votepeloclima/urls.py @@ -20,9 +20,11 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import path, include, re_path -from .candidature.views import AddressView, PublicCandidatureView +from .candidature.views import PublicCandidatureView from .candidature.views.create import CreateUpdateCandidatureView from .candidature.views.oauth import DashboardView, UpdateCandidatureStatusView +from .candidature.views.public import AddressView +from .candidature.views.filters import CandidatureSearchView from .views import home @@ -34,6 +36,7 @@ # Public Routers re_path(r"^candidatura/cadastro/(?P.+)/$", register_view, name="register_step",), path("candidatura/cadastro/", register_view, name="register"), + path('candidatura/busca/', CandidatureSearchView.as_view(), name='candidature_search'), re_path(r"^c(andidatura)*/(?P.+)/$", PublicCandidatureView.as_view(), name='candidate_profile'), # API path('api/candidatura/buscar-endereco/', AddressView.as_view(), name='address'),