diff --git a/application/config/config.php b/application/config/config.php index e9ceece24..adcfe8dab 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -455,7 +455,7 @@ $config['csrf_cookie_name'] = 'csrf_cookie_name'; $config['csrf_expire'] = 7200; $config['csrf_regenerate'] = TRUE; -$config['csrf_exclude_uris'] = array('upload/image'); +$config['csrf_exclude_uris'] = array('upload/image', 'api/.*'); /* |-------------------------------------------------------------------------- diff --git a/application/config/routes.php b/application/config/routes.php index 71993c435..ffb05dbab 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -81,6 +81,9 @@ $route['admin/campagnes/delete/(:any)'] = 'admin/delete_campaign/$1'; $route['admin/campagnes/edit/(:any)'] = 'admin/edit_campaign/$1'; $route['admin/campagnes/toggle'] = 'admin/toggle_campaign_active'; +$route['admin/api-keys'] = 'admin/api_keys'; +$route['admin/api-keys/create'] = 'admin/api_keys_create'; +$route['admin/api-keys/revoke/(:num)'] = 'admin/api_keys_revoke/$1'; // MpDashboard @@ -186,8 +189,28 @@ $route['parrainages-2022'] = 'parrainages/index'; // FAQ $route['faq'] = 'faq/index'; -// API -$route['api/(:any)/(:any)'] = 'api/index/$1/$2'; +// API (secured with API key) +// Votes bruts (votes_info) +$route['api/votes'] = 'api/votes/index'; +$route['api/votes/meta'] = 'api/votes/meta'; +$route['api/votes/(:any)'] = 'api/votes/index/$1'; +// Votes décryptés (votes_datan) +$route['api/decrypted_votes'] = 'api/decrypted_votes/index'; +$route['api/decrypted_votes/meta'] = 'api/decrypted_votes/meta'; +$route['api/decrypted_votes/(:num)'] = 'api/decrypted_votes/index/$1'; +// Votes non décryptés +$route['api/non_decrypted_votes'] = 'api/non_decrypted_votes/index'; +$route['api/non_decrypted_votes/meta'] = 'api/non_decrypted_votes/meta'; +$route['api/non_decrypted_votes/(:any)'] = 'api/non_decrypted_votes/index/$1'; +// Exposés des motifs +$route['api/exposes'] = 'api/exposes/index'; +$route['api/exposes/meta'] = 'api/exposes/meta'; +$route['api/exposes/stats'] = 'api/exposes/stats'; +$route['api/exposes/by_vote/(:num)/(:num)'] = 'api/exposes/by_vote/$1/$2'; +$route['api/exposes/(:num)'] = 'api/exposes/index/$1'; + +// API (public) +$route['api/(:any)/(:any)'] = 'legacy_api/index/$1/$2'; // LOGIN & REGISTER $route['login'] = 'users/login'; $route['register/(:any)'] = 'users/register/$1'; diff --git a/application/controllers/Admin.php b/application/controllers/Admin.php index 0d6eb7311..7d09534ae 100644 --- a/application/controllers/Admin.php +++ b/application/controllers/Admin.php @@ -868,7 +868,87 @@ public function toggle_campaign_active() } $this->campaign_model->set_active_status($id, $is_active); - redirect('admin/campagnes'); - } - } + redirect('admin/campagnes'); + } + + // API KEYS MANAGEMENT (admin only) + + public function api_keys() + { + $data = $this->data; + + if ($data['usernameType'] != 'admin') { + show_404(); + } + + $this->load->model('api_key_model'); + $this->load->model('user_model'); + + $data['title'] = 'Gestion des clés API'; + $data['keys'] = $this->api_key_model->get_all_keys(); + + // Meta + $data['title_meta'] = $data['title'] . ' - Dashboard | Datan'; + + // Views + $this->load->view('dashboard/header', $data); + $this->load->view('dashboard/api-keys/list', $data); + $this->load->view('dashboard/footer'); + } + + public function api_keys_create() + { + $data = $this->data; + + if ($data['usernameType'] != 'admin') { + show_404(); + } + + $this->load->model('api_key_model'); + $this->load->model('user_model'); + + $data['title'] = 'Créer une clé API'; + $data['users'] = $this->user_model->get_team_users(); + + // Available endpoints and methods + $data['endpoints'] = $this->api_key_model->get_available_endpoints(); + + // Form validation + $this->form_validation->set_rules('name', 'Nom', 'required'); + $this->form_validation->set_rules('user_id', 'Utilisateur', 'required'); + + if ($this->form_validation->run() === FALSE) { + $data['title_meta'] = $data['title'] . ' - Dashboard | Datan'; + $this->load->view('dashboard/header', $data); + $this->load->view('dashboard/api-keys/create', $data); + $this->load->view('dashboard/footer'); + } else { + $permissions = $this->input->post('permissions'); + $result = $this->api_key_model->create_key( + $this->input->post('user_id'), + $this->input->post('name'), + $permissions + ); + + // Store the key in flash data to show it once + $this->session->set_flashdata('new_api_key', $result['key']); + redirect('admin/api-keys'); + } + } + + public function api_keys_revoke($id) + { + $data = $this->data; + + if ($data['usernameType'] != 'admin') { + show_404(); + } + + $this->load->model('api_key_model'); + $this->api_key_model->revoke_key($id); + + $this->session->set_flashdata('success', 'Clé API révoquée avec succès'); + redirect('admin/api-keys'); + } + } ?> diff --git a/application/controllers/Api.php b/application/controllers/Legacy_api.php similarity index 98% rename from application/controllers/Api.php rename to application/controllers/Legacy_api.php index a74a17b46..2e533d83c 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Legacy_api.php @@ -1,5 +1,5 @@ load->library('api_auth'); + $this->load->model('api_key_model'); + $this->load->model('admin_model'); + $this->load->model('fields_model'); + $this->load->model('readings_model'); + $this->load->helper('url'); + $this->load->helper('text'); + + $result = $this->api_auth->authenticate(); + if (isset($result['error'])) { + $this->api_auth->response($result, $result['code']); + exit; + } + $this->api_user = $result; + } + + /** + * Point d'entrée pour /api/decrypted_votes et /api/decrypted_votes/{id} + */ + public function index($id = null) + { + $method = $this->input->method(TRUE); + + if ($id === null) { + $permission = $this->api_auth->check_permission('/api/decrypted_votes', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + switch ($method) { + case 'GET': + return $this->list_votes(); + case 'POST': + return $this->create_vote(); + default: + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + } else { + $permission = $this->api_auth->check_permission('/api/decrypted_votes/{id}', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + switch ($method) { + case 'GET': + return $this->get_vote($id); + case 'PUT': + return $this->update_vote($id); + case 'DELETE': + return $this->delete_vote($id); + default: + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + } + } + + /** + * GET /api/decrypted_votes + * Liste les votes décryptés avec pagination, filtres et tri + */ + private function list_votes() + { + // Pagination + $page = max(1, (int)$this->input->get('page') ?: 1); + $per_page = min(500, max(1, (int)$this->input->get('per_page') ?: 50)); + $offset = ($page - 1) * $per_page; + + // Sélection des champs + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + // Filtres + $state = $this->input->get('state'); + $legislature = $this->input->get('legislature'); + $category = $this->input->get('category'); + + // Tri + $sort = $this->input->get('sort') ?: 'created_at'; + if (!in_array($sort, $this->sortable_fields)) { + $sort = 'created_at'; + } + $order = strtoupper($this->input->get('order') ?: 'DESC'); + if (!in_array($order, array('ASC', 'DESC'))) { + $order = 'DESC'; + } + + // Compter le total (requête séparée) + $this->db->from('votes_datan vd'); + $this->apply_filters($state, $legislature, $category); + $total = $this->db->count_all_results(); + + // Construire la sélection avec les champs demandés + $select_parts = $this->build_select($fields); + + // Récupérer les résultats + $this->db->select($select_parts); + $this->db->from('votes_datan vd'); + $this->db->join('fields f', 'f.id = vd.category', 'left'); + $this->db->join('readings r', 'r.id = vd.reading', 'left'); + $this->db->join('users u1', 'u1.id = vd.created_by', 'left'); + $this->db->join('users u2', 'u2.id = vd.modified_by', 'left'); + $this->apply_filters($state, $legislature, $category); + + // Ajuster le tri pour les champs avec alias + $sort_field = $this->get_sort_field($sort); + $this->db->order_by($sort_field, $order); + $this->db->limit($per_page, $offset); + $votes = $this->db->get()->result_array(); + + return $this->api_auth->response(array( + 'success' => true, + 'pagination' => array( + 'page' => $page, + 'per_page' => $per_page, + 'total' => $total, + 'total_pages' => ceil($total / $per_page) + ), + 'filters' => array( + 'state' => $state, + 'legislature' => $legislature, + 'category' => $category + ), + 'sort' => array('field' => $sort, 'order' => $order), + 'fields' => $fields, + 'count' => count($votes), + 'data' => $votes + )); + } + + /** + * GET /api/decrypted_votes/{id} + * Récupère un vote décrypté par son ID + */ + private function get_vote($id) + { + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + $select_parts = $this->build_select($fields); + + $this->db->select($select_parts); + $this->db->from('votes_datan vd'); + $this->db->join('fields f', 'f.id = vd.category', 'left'); + $this->db->join('readings r', 'r.id = vd.reading', 'left'); + $this->db->join('users u1', 'u1.id = vd.created_by', 'left'); + $this->db->join('users u2', 'u2.id = vd.modified_by', 'left'); + $this->db->where('vd.id', $id); + $vote = $this->db->get()->row_array(); + + if (empty($vote)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Vote not found'), 404); + } + + return $this->api_auth->response(array( + 'success' => true, + 'fields' => $fields, + 'data' => $vote + )); + } + + /** + * POST /api/decrypted_votes + * Crée un nouveau vote décrypté + */ + private function create_vote() + { + $input = $this->api_auth->get_json_input(); + + // Validation des champs requis + $required = array('title', 'legislature', 'voteNumero', 'category'); + foreach ($required as $field) { + if (empty($input[$field])) { + return $this->api_auth->response(array( + 'error' => true, + 'message' => "Missing required field: $field" + ), 400); + } + } + + // Préparer les données pour le modèle + $_POST['title'] = $input['title']; + $_POST['legislature'] = $input['legislature']; + $_POST['vote_id'] = $input['voteNumero']; + $_POST['category'] = $input['category']; + $_POST['description'] = isset($input['description']) ? $input['description'] : ''; + $_POST['reading'] = isset($input['reading']) ? $input['reading'] : ''; + + $result = $this->admin_model->create_vote($this->api_user['user_id']); + + if ($result === null) { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Vote already exists for this legislature and vote number' + ), 409); + } + + // Récupérer le vote créé + $vote_id = $this->db->insert_id(); + $vote = $this->admin_model->get_vote_datan($vote_id); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Vote created successfully', + 'data' => $vote + ), 201); + } + + /** + * PUT /api/decrypted_votes/{id} + * Modifie un vote décrypté existant + */ + private function update_vote($id) + { + $vote = $this->admin_model->get_vote_datan($id); + + if (empty($vote)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Vote not found'), 404); + } + + // Vérifier les permissions + if ($vote['state'] === 'published' && $this->api_user['user_type'] !== 'admin') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Only admins can modify published votes' + ), 403); + } + + $input = $this->api_auth->get_json_input(); + + // Préparer les données + $_POST['title'] = isset($input['title']) ? $input['title'] : $vote['title']; + $_POST['category'] = isset($input['category']) ? $input['category'] : $vote['category']; + $_POST['description'] = isset($input['description']) ? $input['description'] : $vote['description']; + $_POST['state'] = isset($input['state']) ? $input['state'] : $vote['state']; + $_POST['reading'] = isset($input['reading']) ? $input['reading'] : $vote['reading']; + + // Valider l'état + if (!in_array($_POST['state'], array('draft', 'published'))) { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Invalid state. Must be "draft" or "published"' + ), 400); + } + + $this->admin_model->modify_vote($id, $this->api_user['user_id']); + + $updated_vote = $this->admin_model->get_vote_datan($id); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Vote updated successfully', + 'data' => $updated_vote + )); + } + + /** + * DELETE /api/decrypted_votes/{id} + * Supprime un vote décrypté + */ + private function delete_vote($id) + { + if ($this->api_user['user_type'] !== 'admin') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Only admins can delete votes' + ), 403); + } + + $vote = $this->admin_model->get_vote_datan($id); + + if (empty($vote)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Vote not found'), 404); + } + + $this->admin_model->delete_vote($id); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Vote deleted successfully', + 'deleted_id' => (int)$id + )); + } + + /** + * Parse et valide les champs demandés + */ + private function parse_fields() + { + $fields_param = $this->input->get('fields'); + if ($fields_param) { + $requested_fields = array_map('trim', explode(',', $fields_param)); + $fields = array_intersect($requested_fields, $this->available_fields); + if (empty($fields)) { + return array( + 'error' => true, + 'message' => 'Invalid fields. Available: ' . implode(', ', $this->available_fields) + ); + } + return $fields; + } + return $this->available_fields; + } + + /** + * Construit la clause SELECT avec les bons alias + */ + private function build_select($fields) + { + $mapping = array( + 'id' => 'vd.id', + 'legislature' => 'vd.legislature', + 'voteNumero' => 'vd.voteNumero', + 'vote_id' => 'vd.vote_id', + 'title' => 'vd.title', + 'slug' => 'vd.slug', + 'category' => 'vd.category', + 'category_name' => 'f.name AS category_name', + 'reading' => 'vd.reading', + 'reading_name' => 'r.name AS reading_name', + 'description' => 'vd.description', + 'state' => 'vd.state', + 'created_at' => 'vd.created_at', + 'modified_at' => 'vd.modified_at', + 'created_by' => 'vd.created_by', + 'created_by_name' => 'u1.name AS created_by_name', + 'modified_by' => 'vd.modified_by', + 'modified_by_name' => 'u2.name AS modified_by_name' + ); + + $select_parts = array(); + foreach ($fields as $field) { + if (isset($mapping[$field])) { + $select_parts[] = $mapping[$field]; + } + } + + return implode(', ', $select_parts); + } + + /** + * Retourne le champ de tri avec le bon préfixe + */ + private function get_sort_field($sort) + { + $mapping = array( + 'id' => 'vd.id', + 'legislature' => 'vd.legislature', + 'voteNumero' => 'vd.voteNumero', + 'title' => 'vd.title', + 'category' => 'vd.category', + 'state' => 'vd.state', + 'created_at' => 'vd.created_at', + 'modified_at' => 'vd.modified_at' + ); + + return isset($mapping[$sort]) ? $mapping[$sort] : 'vd.created_at'; + } + + /** + * Applique les filtres à la requête courante + */ + private function apply_filters($state, $legislature, $category) + { + if ($state && in_array($state, array('draft', 'published'))) { + $this->db->where('vd.state', $state); + } + if ($legislature) { + $this->db->where('vd.legislature', $legislature); + } + if ($category) { + $this->db->where('vd.category', $category); + } + } + + /** + * Retourne les métadonnées de l'endpoint + */ + public function meta() + { + $categories = $this->fields_model->get_fields(); + $readings = $this->readings_model->get(); + + return $this->api_auth->response(array( + 'success' => true, + 'endpoint' => '/api/decrypted_votes', + 'description' => 'Votes décryptés par Datan (votes_datan)', + 'methods' => array( + 'GET /api/decrypted_votes' => 'Lister les votes décryptés', + 'GET /api/decrypted_votes/{id}' => 'Voir un vote décrypté', + 'POST /api/decrypted_votes' => 'Créer un vote décrypté', + 'PUT /api/decrypted_votes/{id}' => 'Modifier un vote décrypté', + 'DELETE /api/decrypted_votes/{id}' => 'Supprimer un vote décrypté (admin uniquement)' + ), + 'post_fields' => array( + 'required' => array( + 'title' => 'Titre du vote', + 'legislature' => 'Numéro de législature', + 'voteNumero' => 'Numéro du vote dans la législature', + 'category' => 'ID de la catégorie (voir categories ci-dessous)' + ), + 'optional' => array( + 'description' => 'Description du vote (défaut: vide)', + 'reading' => 'ID de la lecture (voir readings ci-dessous, défaut: null)' + ), + 'auto_generated' => array( + 'id', 'vote_id', 'slug', 'state (draft)', 'created_at', 'created_by', 'created_by_name' + ), + 'example' => array( + 'title' => 'Projet de loi de finances 2025', + 'legislature' => '17', + 'voteNumero' => '1470', + 'category' => '1', + 'description' => 'Vote sur le budget général', + 'reading' => '1' + ) + ), + 'put_fields' => array( + 'optional' => array( + 'title' => 'Titre du vote', + 'category' => 'ID de la catégorie', + 'description' => 'Description du vote', + 'reading' => 'ID de la lecture', + 'state' => 'État du vote (draft, published)' + ), + 'auto_generated' => array( + 'slug', 'modified_at', 'modified_by', 'modified_by_name' + ), + 'notes' => 'Seuls les admins peuvent modifier un vote publié. Seuls les champs envoyés sont modifiés.', + 'example' => array( + 'title' => 'Titre modifié', + 'state' => 'published' + ) + ), + 'available_fields' => $this->available_fields, + 'sortable_fields' => $this->sortable_fields, + 'filters' => array( + 'state' => 'État du vote (draft, published)', + 'legislature' => 'Numéro de législature', + 'category' => 'ID de la catégorie' + ), + 'pagination' => array( + 'page' => 'Numéro de page (défaut: 1)', + 'per_page' => 'Résultats par page (défaut: 50, max: 500)' + ), + 'sorting' => array( + 'sort' => 'Champ de tri (défaut: created_at)', + 'order' => 'Ordre de tri: ASC ou DESC (défaut: DESC)' + ), + 'categories' => $categories, + 'readings' => $readings + )); + } +} diff --git a/application/controllers/api/Exposes.php b/application/controllers/api/Exposes.php new file mode 100644 index 000000000..76b9355c5 --- /dev/null +++ b/application/controllers/api/Exposes.php @@ -0,0 +1,417 @@ +load->library('api_auth'); + $this->load->model('api_key_model'); + $this->load->model('exposes_model'); + + $result = $this->api_auth->authenticate(); + if (isset($result['error'])) { + $this->api_auth->response($result, $result['code']); + exit; + } + $this->api_user = $result; + } + + /** + * Point d'entrée pour /api/exposes et /api/exposes/{id} + */ + public function index($id = null) + { + $method = $this->input->method(TRUE); + + if ($id === null) { + $permission = $this->api_auth->check_permission('/api/exposes', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + switch ($method) { + case 'GET': + return $this->list_exposes(); + case 'POST': + return $this->create_expose(); + default: + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + } else { + $permission = $this->api_auth->check_permission('/api/exposes/{id}', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + switch ($method) { + case 'GET': + return $this->get_expose($id); + case 'PUT': + return $this->update_expose($id); + case 'DELETE': + return $this->delete_expose($id); + default: + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + } + } + + /** + * GET /api/exposes + * Liste les exposés avec pagination et filtres + */ + private function list_exposes() + { + // Pagination + $page = max(1, (int)$this->input->get('page') ?: 1); + $per_page = min(500, max(1, (int)$this->input->get('per_page') ?: 50)); + $offset = ($page - 1) * $per_page; + + // Sélection des champs + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + // Filtres + $legislature = $this->input->get('legislature'); + $status = $this->input->get('status'); // 'pending', 'done', 'all' + + // Tri + $sort = $this->input->get('sort') ?: 'dateMaj'; + if (!in_array($sort, $this->sortable_fields)) { + $sort = 'dateMaj'; + } + $order = strtoupper($this->input->get('order') ?: 'DESC'); + if (!in_array($order, array('ASC', 'DESC'))) { + $order = 'DESC'; + } + + // Compter le total (requête séparée) + $this->db->from('exposes'); + $this->apply_filters($legislature, $status); + $total = $this->db->count_all_results(); + + // Récupérer les résultats avec pagination + $this->db->select(implode(', ', $fields)); + $this->db->from('exposes'); + $this->apply_filters($legislature, $status); + $this->db->order_by($sort, $order); + $this->db->limit($per_page, $offset); + $exposes = $this->db->get()->result_array(); + + return $this->api_auth->response(array( + 'success' => true, + 'pagination' => array( + 'page' => $page, + 'per_page' => $per_page, + 'total' => $total, + 'total_pages' => ceil($total / $per_page) + ), + 'filters' => array( + 'legislature' => $legislature, + 'status' => $status + ), + 'sort' => array('field' => $sort, 'order' => $order), + 'fields' => $fields, + 'count' => count($exposes), + 'data' => $exposes + )); + } + + /** + * GET /api/exposes/{id} + * Récupère un exposé par son ID + */ + private function get_expose($id) + { + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + $this->db->select(implode(', ', $fields)); + $expose = $this->db->get_where('exposes', array('id' => $id))->row_array(); + + if (empty($expose)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Expose not found'), 404); + } + + return $this->api_auth->response(array( + 'success' => true, + 'fields' => $fields, + 'data' => $expose + )); + } + + /** + * GET /api/exposes/by_vote/{legislature}/{voteNumero} + * Récupère un exposé par legislature et voteNumero + */ + public function by_vote($legislature, $voteNumero) + { + $method = $this->input->method(TRUE); + + if ($method !== 'GET') { + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + + $permission = $this->api_auth->check_permission('/api/exposes/{id}', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + $this->db->select(implode(', ', $fields)); + $expose = $this->db->get_where('exposes', array( + 'legislature' => $legislature, + 'voteNumero' => $voteNumero + ))->row_array(); + + if (empty($expose)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Expose not found'), 404); + } + + return $this->api_auth->response(array( + 'success' => true, + 'fields' => $fields, + 'data' => $expose + )); + } + + /** + * POST /api/exposes + * Crée un nouvel exposé + */ + private function create_expose() + { + $input = $this->api_auth->get_json_input(); + + // Validation des champs requis + $required = array('legislature', 'voteNumero'); + foreach ($required as $field) { + if (!isset($input[$field]) || $input[$field] === '') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => "Missing required field: $field" + ), 400); + } + } + + // Vérifier si l'exposé existe déjà + $existing = $this->exposes_model->get_expose_by_vote($input['legislature'], $input['voteNumero']); + if (!empty($existing)) { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Expose already exists for this legislature and voteNumero' + ), 409); + } + + $data = array( + 'legislature' => $input['legislature'], + 'voteNumero' => $input['voteNumero'], + 'exposeOriginal' => isset($input['exposeOriginal']) ? $input['exposeOriginal'] : null, + 'exposeSummary' => isset($input['exposeSummary']) ? $input['exposeSummary'] : null, + 'exposeSummaryPublished' => isset($input['exposeSummaryPublished']) ? $input['exposeSummaryPublished'] : null, + 'dateMaj' => date('Y-m-d H:i:s') + ); + + $this->db->insert('exposes', $data); + $expose_id = $this->db->insert_id(); + + $expose = $this->exposes_model->get_expose($expose_id); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Expose created successfully', + 'data' => $expose + ), 201); + } + + /** + * PUT /api/exposes/{id} + * Modifie un exposé existant + */ + private function update_expose($id) + { + $expose = $this->exposes_model->get_expose($id); + + if (empty($expose)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Expose not found'), 404); + } + + $input = $this->api_auth->get_json_input(); + + $data = array( + 'dateMaj' => date('Y-m-d H:i:s') + ); + + // Champs modifiables + if (isset($input['exposeOriginal'])) { + $data['exposeOriginal'] = $input['exposeOriginal']; + } + if (isset($input['exposeSummary'])) { + $data['exposeSummary'] = $input['exposeSummary']; + } + if (isset($input['exposeSummaryPublished'])) { + $data['exposeSummaryPublished'] = $input['exposeSummaryPublished']; + } + + $this->db->where('id', $id); + $this->db->update('exposes', $data); + + $updated_expose = $this->exposes_model->get_expose($id); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Expose updated successfully', + 'data' => $updated_expose + )); + } + + /** + * DELETE /api/exposes/{id} + * Supprime un exposé + */ + private function delete_expose($id) + { + if ($this->api_user['user_type'] !== 'admin') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Only admins can delete exposes' + ), 403); + } + + $expose = $this->exposes_model->get_expose($id); + + if (empty($expose)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Expose not found'), 404); + } + + $this->db->delete('exposes', array('id' => $id)); + + return $this->api_auth->response(array( + 'success' => true, + 'message' => 'Expose deleted successfully', + 'deleted_id' => (int)$id + )); + } + + /** + * Parse et valide les champs demandés + */ + private function parse_fields() + { + $fields_param = $this->input->get('fields'); + if ($fields_param) { + $requested_fields = array_map('trim', explode(',', $fields_param)); + $fields = array_intersect($requested_fields, $this->available_fields); + if (empty($fields)) { + return array( + 'error' => true, + 'message' => 'Invalid fields. Available: ' . implode(', ', $this->available_fields) + ); + } + return $fields; + } + return $this->available_fields; + } + + /** + * Applique les filtres à la requête courante + */ + private function apply_filters($legislature, $status) + { + if ($legislature) { + $this->db->where('legislature', $legislature); + } + if ($status === 'pending') { + $this->db->where('exposeSummaryPublished IS NULL', null, false); + } elseif ($status === 'done') { + $this->db->where('exposeSummaryPublished IS NOT NULL', null, false); + } + } + + /** + * Retourne les métadonnées de l'endpoint + */ + public function meta() + { + return $this->api_auth->response(array( + 'success' => true, + 'endpoint' => '/api/exposes', + 'description' => 'Exposés des motifs des amendements avec résumés IA', + 'methods' => array('GET', 'POST', 'PUT', 'DELETE'), + 'available_fields' => $this->available_fields, + 'sortable_fields' => $this->sortable_fields, + 'filters' => array( + 'legislature' => 'Numéro de législature', + 'status' => 'Statut: pending (non publié), done (publié), all (tous)' + ), + 'pagination' => array( + 'page' => 'Numéro de page (défaut: 1)', + 'per_page' => 'Résultats par page (défaut: 50, max: 500)' + ), + 'sorting' => array( + 'sort' => 'Champ de tri (défaut: dateMaj)', + 'order' => 'Ordre de tri: ASC ou DESC (défaut: DESC)' + ), + 'special_routes' => array( + '/api/exposes/by_vote/{legislature}/{voteNumero}' => 'Récupérer un exposé par legislature et voteNumero' + ) + )); + } + + /** + * Statistiques des exposés + */ + public function stats() + { + $method = $this->input->method(TRUE); + + if ($method !== 'GET') { + return $this->api_auth->response(array('error' => true, 'message' => 'Method not allowed'), 405); + } + + $permission = $this->api_auth->check_permission('/api/exposes', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + $total = $this->db->count_all('exposes'); + $done = $this->exposes_model->get_n_done(); + $pending = $this->exposes_model->get_n_pending(); + + return $this->api_auth->response(array( + 'success' => true, + 'data' => array( + 'total' => $total, + 'done' => $done, + 'pending' => $pending, + 'percentage_done' => $total > 0 ? round(($done / $total) * 100, 2) : 0 + ) + )); + } +} diff --git a/application/controllers/api/Non_decrypted_votes.php b/application/controllers/api/Non_decrypted_votes.php new file mode 100644 index 000000000..b4b38f554 --- /dev/null +++ b/application/controllers/api/Non_decrypted_votes.php @@ -0,0 +1,262 @@ +load->library('api_auth'); + $this->load->model('api_key_model'); + + $result = $this->api_auth->authenticate(); + if (isset($result['error'])) { + $this->api_auth->response($result, $result['code']); + exit; + } + $this->api_user = $result; + } + + /** + * Point d'entrée pour /api/non_decrypted_votes + */ + public function index($id = null) + { + $method = $this->input->method(TRUE); + + if ($method !== 'GET') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Method not allowed. Only GET is supported.' + ), 405); + } + + $permission = $this->api_auth->check_permission('/api/non_decrypted_votes', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + + if ($id === null) { + return $this->list_votes(); + } else { + return $this->get_vote($id); + } + } + + /** + * GET /api/non_decrypted_votes + * Liste les votes qui ne sont pas encore décryptés + */ + private function list_votes() + { + // Pagination + $page = max(1, (int)$this->input->get('page') ?: 1); + $per_page = min(500, max(1, (int)$this->input->get('per_page') ?: 50)); + $offset = ($page - 1) * $per_page; + + // Sélection des champs + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + // Filtres + $legislature = $this->input->get('legislature'); + $year = $this->input->get('year'); + $month = $this->input->get('month'); + $vote_type = $this->input->get('vote_type'); + $sort_code = $this->input->get('sort_code'); + + // Tri + $sort = $this->input->get('sort') ?: 'dateScrutin'; + if (!in_array($sort, $this->sortable_fields)) { + $sort = 'dateScrutin'; + } + $order = strtoupper($this->input->get('order') ?: 'DESC'); + if (!in_array($order, array('ASC', 'DESC'))) { + $order = 'DESC'; + } + + // Compter le total (requête séparée) + $this->db->from('votes_info vi'); + $this->db->join('votes_datan vd', 'vi.voteId = vd.vote_id', 'left'); + $this->db->where('vd.id IS NULL', null, false); + $this->apply_filters($legislature, $year, $month, $vote_type, $sort_code); + $total = $this->db->count_all_results(); + + // Construire la sélection avec les champs demandés + $select_parts = array(); + foreach ($fields as $field) { + $select_parts[] = 'vi.' . $field; + } + + // Récupérer les résultats avec pagination + $this->db->select(implode(', ', $select_parts)); + $this->db->from('votes_info vi'); + $this->db->join('votes_datan vd', 'vi.voteId = vd.vote_id', 'left'); + $this->db->where('vd.id IS NULL', null, false); + $this->apply_filters($legislature, $year, $month, $vote_type, $sort_code); + $this->db->order_by('vi.' . $sort, $order); + $this->db->limit($per_page, $offset); + $votes = $this->db->get()->result_array(); + + return $this->api_auth->response(array( + 'success' => true, + 'pagination' => array( + 'page' => $page, + 'per_page' => $per_page, + 'total' => $total, + 'total_pages' => ceil($total / $per_page) + ), + 'filters' => array( + 'legislature' => $legislature, + 'year' => $year, + 'month' => $month, + 'vote_type' => $vote_type, + 'sort_code' => $sort_code + ), + 'sort' => array('field' => $sort, 'order' => $order), + 'fields' => $fields, + 'count' => count($votes), + 'data' => $votes + )); + } + + /** + * GET /api/non_decrypted_votes/{id} + * Récupère un vote non décrypté par son voteId + */ + private function get_vote($id) + { + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + $select_parts = array(); + foreach ($fields as $field) { + $select_parts[] = 'vi.' . $field; + } + + $this->db->select(implode(', ', $select_parts)); + $this->db->from('votes_info vi'); + $this->db->join('votes_datan vd', 'vi.voteId = vd.vote_id', 'left'); + $this->db->where('vd.id IS NULL', null, false); + $this->db->where('vi.voteId', $id); + $vote = $this->db->get()->row_array(); + + if (empty($vote) && is_numeric($id)) { + $this->db->select(implode(', ', $select_parts)); + $this->db->from('votes_info vi'); + $this->db->join('votes_datan vd', 'vi.voteId = vd.vote_id', 'left'); + $this->db->where('vd.id IS NULL', null, false); + $this->db->where('vi.voteNumero', $id); + $vote = $this->db->get()->row_array(); + } + + if (empty($vote)) { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Vote not found or already decrypted' + ), 404); + } + + return $this->api_auth->response(array( + 'success' => true, + 'fields' => $fields, + 'data' => $vote + )); + } + + /** + * Parse et valide les champs demandés + */ + private function parse_fields() + { + $fields_param = $this->input->get('fields'); + if ($fields_param) { + $requested_fields = array_map('trim', explode(',', $fields_param)); + $fields = array_intersect($requested_fields, $this->available_fields); + if (empty($fields)) { + return array( + 'error' => true, + 'message' => 'Invalid fields. Available: ' . implode(', ', $this->available_fields) + ); + } + return $fields; + } + return $this->available_fields; + } + + /** + * Applique les filtres à la requête courante + */ + private function apply_filters($legislature, $year, $month, $vote_type, $sort_code) + { + if ($legislature) { + $this->db->where('vi.legislature', $legislature); + } + if ($year) { + $this->db->where('YEAR(vi.dateScrutin)', $year); + } + if ($month) { + $this->db->where('MONTH(vi.dateScrutin)', $month); + } + if ($vote_type) { + $this->db->where('vi.voteType', $vote_type); + } + if ($sort_code) { + $this->db->where('vi.sortCode', $sort_code); + } + } + + /** + * Retourne les métadonnées de l'endpoint + */ + public function meta() + { + return $this->api_auth->response(array( + 'success' => true, + 'endpoint' => '/api/non_decrypted_votes', + 'description' => 'Votes de l\'Assemblée nationale pas encore décryptés par Datan', + 'methods' => array('GET'), + 'available_fields' => $this->available_fields, + 'sortable_fields' => $this->sortable_fields, + 'filters' => array( + 'legislature' => 'Numéro de législature (ex: 17)', + 'year' => 'Année du scrutin (ex: 2024)', + 'month' => 'Mois du scrutin (1-12)', + 'vote_type' => 'Type de vote (final, amendement, article, etc.)', + 'sort_code' => 'Résultat du vote (adopté, rejeté)' + ), + 'pagination' => array( + 'page' => 'Numéro de page (défaut: 1)', + 'per_page' => 'Résultats par page (défaut: 50, max: 500)' + ), + 'sorting' => array( + 'sort' => 'Champ de tri (défaut: dateScrutin)', + 'order' => 'Ordre de tri: ASC ou DESC (défaut: DESC)' + ) + )); + } +} diff --git a/application/controllers/api/Votes.php b/application/controllers/api/Votes.php new file mode 100644 index 000000000..4572ddec5 --- /dev/null +++ b/application/controllers/api/Votes.php @@ -0,0 +1,239 @@ +load->library('api_auth'); + $this->load->model('api_key_model'); + + $result = $this->api_auth->authenticate(); + if (isset($result['error'])) { + $this->api_auth->response($result, $result['code']); + exit; + } + $this->api_user = $result; + } + + /** + * Point d'entrée pour /api/votes et /api/votes/{id} + */ + public function index($id = null) + { + $method = $this->input->method(TRUE); + + if ($method !== 'GET') { + return $this->api_auth->response(array( + 'error' => true, + 'message' => 'Method not allowed. Only GET is supported for votes_info.' + ), 405); + } + + if ($id === null) { + $permission = $this->api_auth->check_permission('/api/votes', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + return $this->list_votes(); + } else { + $permission = $this->api_auth->check_permission('/api/votes/{id}', $method); + if ($permission !== true) { + return $this->api_auth->response($permission, $permission['code']); + } + return $this->get_vote($id); + } + } + + /** + * GET /api/votes + * Liste les votes avec pagination et sélection de champs + */ + private function list_votes() + { + // Pagination + $page = max(1, (int)$this->input->get('page') ?: 1); + $per_page = min(500, max(1, (int)$this->input->get('per_page') ?: 50)); + $offset = ($page - 1) * $per_page; + + // Sélection des champs + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + // Filtres + $legislature = $this->input->get('legislature'); + $year = $this->input->get('year'); + $month = $this->input->get('month'); + $vote_type = $this->input->get('vote_type'); + $sort_code = $this->input->get('sort_code'); + + // Tri + $sort = $this->input->get('sort') ?: 'dateScrutin'; + if (!in_array($sort, $this->sortable_fields)) { + $sort = 'dateScrutin'; + } + $order = strtoupper($this->input->get('order') ?: 'DESC'); + if (!in_array($order, array('ASC', 'DESC'))) { + $order = 'DESC'; + } + + // Compter le total (requête séparée) + $this->db->from('votes_info'); + $this->apply_filters($legislature, $year, $month, $vote_type, $sort_code); + $total = $this->db->count_all_results(); + + // Récupérer les résultats avec pagination (nouvelle requête) + $this->db->select(implode(', ', $fields)); + $this->db->from('votes_info'); + $this->apply_filters($legislature, $year, $month, $vote_type, $sort_code); + $this->db->order_by($sort, $order); + $this->db->limit($per_page, $offset); + $votes = $this->db->get()->result_array(); + + return $this->api_auth->response(array( + 'success' => true, + 'pagination' => array( + 'page' => $page, + 'per_page' => $per_page, + 'total' => $total, + 'total_pages' => ceil($total / $per_page) + ), + 'filters' => array( + 'legislature' => $legislature, + 'year' => $year, + 'month' => $month, + 'vote_type' => $vote_type, + 'sort_code' => $sort_code + ), + 'sort' => array('field' => $sort, 'order' => $order), + 'fields' => $fields, + 'count' => count($votes), + 'data' => $votes + )); + } + + /** + * GET /api/votes/{id} + * Récupère un vote par son voteId ou voteNumero + */ + private function get_vote($id) + { + $fields = $this->parse_fields(); + if (isset($fields['error'])) { + return $this->api_auth->response($fields, 400); + } + + $this->db->select(implode(', ', $fields)); + $vote = $this->db->get_where('votes_info', array('voteId' => $id))->row_array(); + + if (empty($vote) && is_numeric($id)) { + $this->db->select(implode(', ', $fields)); + $vote = $this->db->get_where('votes_info', array('voteNumero' => $id))->row_array(); + } + + if (empty($vote)) { + return $this->api_auth->response(array('error' => true, 'message' => 'Vote not found'), 404); + } + + return $this->api_auth->response(array( + 'success' => true, + 'fields' => $fields, + 'data' => $vote + )); + } + + /** + * Parse et valide les champs demandés + */ + private function parse_fields() + { + $fields_param = $this->input->get('fields'); + if ($fields_param) { + $requested_fields = array_map('trim', explode(',', $fields_param)); + $fields = array_intersect($requested_fields, $this->available_fields); + if (empty($fields)) { + return array( + 'error' => true, + 'message' => 'Invalid fields. Available: ' . implode(', ', $this->available_fields) + ); + } + return $fields; + } + return $this->available_fields; + } + + /** + * Applique les filtres à la requête courante + */ + private function apply_filters($legislature, $year, $month, $vote_type, $sort_code) + { + if ($legislature) { + $this->db->where('legislature', $legislature); + } + if ($year) { + $this->db->where('YEAR(dateScrutin)', $year); + } + if ($month) { + $this->db->where('MONTH(dateScrutin)', $month); + } + if ($vote_type) { + $this->db->where('voteType', $vote_type); + } + if ($sort_code) { + $this->db->where('sortCode', $sort_code); + } + } + + /** + * Retourne les métadonnées de l'endpoint + */ + public function meta() + { + return $this->api_auth->response(array( + 'success' => true, + 'endpoint' => '/api/votes', + 'description' => 'Votes bruts de l\'Assemblée nationale (votes_info)', + 'methods' => array('GET'), + 'available_fields' => $this->available_fields, + 'sortable_fields' => $this->sortable_fields, + 'filters' => array( + 'legislature' => 'Numéro de législature (ex: 17)', + 'year' => 'Année du scrutin (ex: 2024)', + 'month' => 'Mois du scrutin (1-12)', + 'vote_type' => 'Type de vote (final, amendement, article, etc.)', + 'sort_code' => 'Résultat du vote (adopté, rejeté)' + ), + 'pagination' => array( + 'page' => 'Numéro de page (défaut: 1)', + 'per_page' => 'Résultats par page (défaut: 50, max: 500)' + ), + 'sorting' => array( + 'sort' => 'Champ de tri (défaut: dateScrutin)', + 'order' => 'Ordre de tri: ASC ou DESC (défaut: DESC)' + ) + )); + } +} diff --git a/application/libraries/Api_auth.php b/application/libraries/Api_auth.php new file mode 100644 index 000000000..7ed1391fa --- /dev/null +++ b/application/libraries/Api_auth.php @@ -0,0 +1,91 @@ +CI =& get_instance(); + $this->CI->load->model('api_key_model'); + } + + /** + * Authentifie la requête via clé API + * @return array|null Les infos utilisateur ou null si échec + */ + public function authenticate() + { + $auth_header = $this->CI->input->get_request_header('Authorization', TRUE); + + if (empty($auth_header)) { + return array('error' => true, 'message' => 'Authorization header missing', 'code' => 401); + } + + if (strpos($auth_header, 'Bearer ') !== 0) { + return array('error' => true, 'message' => 'Invalid authorization format. Use: Bearer ', 'code' => 401); + } + + $api_key = substr($auth_header, 7); + $this->api_user = $this->CI->api_key_model->validate_key($api_key); + + if ($this->api_user === null) { + return array('error' => true, 'message' => 'Invalid or inactive API key', 'code' => 401); + } + + // Vérifier que l'utilisateur est admin ou writer + if (!in_array($this->api_user['user_type'], array('admin', 'writer'))) { + return array('error' => true, 'message' => 'Insufficient permissions', 'code' => 403); + } + + return $this->api_user; + } + + /** + * Retourne l'utilisateur API authentifié + */ + public function get_user() + { + return $this->api_user; + } + + /** + * Vérifie les permissions pour un endpoint et une méthode + */ + public function check_permission($endpoint, $method) + { + if (!$this->CI->api_key_model->has_permission($this->api_user, $endpoint, $method)) { + return array( + 'error' => true, + 'message' => "Permission denied for $method $endpoint", + 'code' => 403 + ); + } + return true; + } + + /** + * Retourne une réponse JSON + */ + public function response($data, $code = 200) + { + return $this->CI->output + ->set_content_type('application/json') + ->set_status_header($code) + ->set_output(json_encode($data)); + } + + /** + * Récupère les données JSON du body de la requête + */ + public function get_json_input() + { + $input = file_get_contents('php://input'); + return json_decode($input, true) ?: array(); + } +} diff --git a/application/models/Admin_model.php b/application/models/Admin_model.php index 89d1ed90a..52c3bcedf 100644 --- a/application/models/Admin_model.php +++ b/application/models/Admin_model.php @@ -145,7 +145,9 @@ public function get_vote_datan($id) { $this->db->join('fields', 'fields.id = votes_datan.category', 'left'); $this->db->join('readings', 'readings.id = votes_datan.reading', 'left'); - $this->db->select('votes_datan.*, fields.name AS category_name, readings.name AS reading_name'); + $this->db->join('users u1', 'u1.id = votes_datan.created_by', 'left'); + $this->db->join('users u2', 'u2.id = votes_datan.modified_by', 'left'); + $this->db->select('votes_datan.*, fields.name AS category_name, readings.name AS reading_name, u1.name AS created_by_name, u2.name AS modified_by_name'); $query = $this->db->get_where('votes_datan', array('votes_datan.id' => $id), 1); return $query->row_array(); diff --git a/application/models/Api_key_model.php b/application/models/Api_key_model.php new file mode 100644 index 000000000..991a078d9 --- /dev/null +++ b/application/models/Api_key_model.php @@ -0,0 +1,237 @@ + array( + 'GET' => 'Lister les votes (votes_info)' + ), + '/api/votes/{id}' => array( + 'GET' => 'Voir un vote (votes_info)' + ), + // Votes décryptés (votes_datan) + '/api/decrypted_votes' => array( + 'GET' => 'Lister les votes décryptés', + 'POST' => 'Créer un vote décrypté' + ), + '/api/decrypted_votes/{id}' => array( + 'GET' => 'Voir un vote décrypté', + 'PUT' => 'Modifier un vote décrypté', + 'DELETE' => 'Supprimer un vote décrypté' + ), + // Votes non décryptés + '/api/non_decrypted_votes' => array( + 'GET' => 'Lister les votes non décryptés' + ), + // Exposés des motifs + '/api/exposes' => array( + 'GET' => 'Lister les exposés', + 'POST' => 'Créer un exposé' + ), + '/api/exposes/{id}' => array( + 'GET' => 'Voir un exposé', + 'PUT' => 'Modifier un exposé', + 'DELETE' => 'Supprimer un exposé' + ) + ); + } + + /** + * Valide une clé API et retourne les infos de l'utilisateur associé + * + * @param string $api_key La clé API en clair + * @return array|null Les infos utilisateur ou null si invalide + */ + public function validate_key($api_key) + { + $key_hash = hash('sha256', $api_key); + $key_prefix = substr($api_key, 0, 8); + + $this->db->select('ak.*, u.id as user_id, u.name as user_name, u.type as user_type'); + $this->db->from('api_keys ak'); + $this->db->join('users u', 'u.id = ak.user_id'); + $this->db->where('ak.key_hash', $key_hash); + $this->db->where('ak.key_prefix', $key_prefix); + $this->db->where('ak.is_active', 1); + $query = $this->db->get(); + + if ($query->num_rows() === 1) { + $result = $query->row_array(); + $result['permissions'] = json_decode($result['permissions'], true); + $this->update_last_used($result['id']); + return $result; + } + + return null; + } + + /** + * Vérifie si une clé a la permission pour un endpoint et une méthode + * + * @param array $api_user Les infos de la clé API (retournées par validate_key) + * @param string $endpoint L'endpoint demandé (ex: /api/admin/votes) + * @param string $method La méthode HTTP (GET, POST, PUT, DELETE) + * @return bool + */ + public function has_permission($api_user, $endpoint, $method) + { + $permissions = $api_user['permissions']; + + // Si pas de permissions définies, tout est autorisé (rétrocompatibilité) + if (empty($permissions)) { + return true; + } + + // Normaliser l'endpoint (remplacer les IDs par {id}) + $normalized_endpoint = preg_replace('/\/\d+$/', '/{id}', $endpoint); + + // Vérifier la permission exacte + if (isset($permissions[$normalized_endpoint])) { + return in_array($method, $permissions[$normalized_endpoint]); + } + + // Vérifier aussi l'endpoint de base pour /api/admin/votes + if (isset($permissions[$endpoint])) { + return in_array($method, $permissions[$endpoint]); + } + + return false; + } + + /** + * Met à jour la date de dernière utilisation + * + * @param int $key_id ID de la clé + */ + public function update_last_used($key_id) + { + $this->db->set('last_used_at', date('Y-m-d H:i:s')); + $this->db->where('id', $key_id); + $this->db->update('api_keys'); + } + + /** + * Génère une nouvelle clé API + * + * @param int $user_id ID de l'utilisateur + * @param string $name Nom descriptif de la clé + * @param array|null $permissions Permissions (endpoint => [methods]) + * @return array ['key' => clé en clair (à afficher une seule fois), 'id' => id en BDD] + */ + public function create_key($user_id, $name, $permissions = null) + { + // Génère une clé aléatoire de 32 caractères avec préfixe + $random_bytes = bin2hex(random_bytes(24)); + $api_key = 'datan_' . $random_bytes; + + $key_hash = hash('sha256', $api_key); + $key_prefix = substr($api_key, 0, 8); + + // Nettoyer les permissions vides + if (is_array($permissions)) { + $permissions = array_filter($permissions, function($methods) { + return !empty($methods); + }); + } + + $data = array( + 'name' => $name, + 'key_hash' => $key_hash, + 'key_prefix' => $key_prefix, + 'user_id' => $user_id, + 'permissions' => !empty($permissions) ? json_encode($permissions) : null, + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s') + ); + + $this->db->insert('api_keys', $data); + + return array( + 'key' => $api_key, + 'id' => $this->db->insert_id() + ); + } + + /** + * Désactive une clé API + * + * @param int $key_id ID de la clé + * @return bool + */ + public function revoke_key($key_id) + { + $this->db->set('is_active', 0); + $this->db->where('id', $key_id); + return $this->db->update('api_keys'); + } + + /** + * Liste les clés d'un utilisateur + * + * @param int $user_id ID de l'utilisateur + * @return array + */ + public function get_keys_by_user($user_id) + { + $this->db->select('id, name, key_prefix, permissions, is_active, created_at, last_used_at'); + $this->db->where('user_id', $user_id); + $this->db->order_by('created_at', 'DESC'); + $query = $this->db->get('api_keys'); + return $query->result_array(); + } + + /** + * Liste toutes les clés API avec infos utilisateur + * + * @return array + */ + public function get_all_keys() + { + $this->db->select('ak.id, ak.name, ak.key_prefix, ak.permissions, ak.is_active, ak.created_at, ak.last_used_at, u.name as user_name, u.type as user_type'); + $this->db->from('api_keys ak'); + $this->db->join('users u', 'u.id = ak.user_id'); + $this->db->order_by('ak.created_at', 'DESC'); + $query = $this->db->get(); + $results = $query->result_array(); + + // Décoder les permissions JSON + foreach ($results as &$row) { + $row['permissions'] = json_decode($row['permissions'], true); + } + + return $results; + } + + /** + * Formate les permissions pour affichage + * + * @param array|null $permissions + * @return string + */ + public function format_permissions_for_display($permissions) + { + if (empty($permissions)) { + return 'Toutes'; + } + + $parts = array(); + foreach ($permissions as $endpoint => $methods) { + $short_endpoint = str_replace('/api/', '', $endpoint); + $parts[] = $short_endpoint . ': ' . implode(', ', $methods); + } + + return implode(' | ', $parts); + } +} diff --git a/application/models/User_model.php b/application/models/User_model.php index 6bb079b26..894dac960 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -124,7 +124,13 @@ public function insert_users_mp($mpId, $user){ 'user' => $user ); return $this->db->insert('users_mp', $data); + } + public function get_team_users(){ + $this->db->select('id, name, username, type'); + $this->db->where_in('type', array('admin', 'writer')); + $this->db->order_by('name', 'ASC'); + return $this->db->get('users')->result_array(); } } diff --git a/application/views/dashboard/api-keys/create.php b/application/views/dashboard/api-keys/create.php new file mode 100644 index 000000000..31faa853e --- /dev/null +++ b/application/views/dashboard/api-keys/create.php @@ -0,0 +1,296 @@ +
+
+
+
+
+

+
+
+
+
+
+
+
+ 'post']) ?> + + +
+ +
+ + +
+ + + Un nom descriptif pour identifier l'usage de cette clé +
+ +
+ + + La clé héritera des permissions de base de cet utilisateur +
+ +
+
+
Permissions de la clé API
+
+
+

Sélectionnez les endpoints et méthodes autorisés pour cette clé.

+ + $methods): ?> +
+
+ +
+ + +
+
+
+
+ $description): ?> +
+
+ > + +
+
+ +
+
+
+ + +
+ + +
+
+
+ +
+ + Important : La clé API sera affichée une seule fois après sa création. + Assurez-vous de la copier et de la stocker en lieu sûr. +
+ + + Annuler + + +
+ + +
+
+
+
Documentation API
+
+
+ + +
Authentification
+

Ajoutez la clé API dans le header de chaque requête :

+
Authorization: Bearer votre_cle_api
+ + +
Endpoints disponibles
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointMéthodesDescription
/api/votesGETVotes bruts de l'Assemblée
/api/non_decrypted_votesGETVotes non encore décryptés
/api/decrypted_votes + GET + POST + PUT + DELETE + Votes décryptés par Datan
/api/exposes + GET + POST + PUT + DELETE + Exposés des motifs
+ + +
Méthodes HTTP
+
    +
  • GET Lecture des données
  • +
  • POST Création d'une ressource
  • +
  • PUT Modification d'une ressource
  • +
  • DELETE Suppression (admin uniquement)
  • +
+ + +
Pagination
+ + + +
pageNuméro de page (défaut: 1)
per_pageRésultats par page (défaut: 50, max: 500)
+ + +
Tri
+ + + +
sortChamp de tri (ex: dateScrutin, created_at)
orderASC (croissant) ou DESC (décroissant)
+ + +
Sélection des champs
+

Limitez les champs retournés avec le paramètre fields :

+
GET /api/votes?fields=voteId,titre,dateScrutin
+ + +
Filtres par endpoint
+ +

/api/votes et /api/non_decrypted_votes

+ + + + + + +
legislatureNuméro de législature (ex: 17)
yearAnnée du scrutin (ex: 2024)
monthMois du scrutin (1-12)
vote_typeType de vote
sort_codeRésultat (adopté, rejeté)
+ +

/api/decrypted_votes

+ + + + +
legislatureNuméro de législature
statedraft ou published
categoryID de la catégorie
+ +

/api/exposes

+ + + +
legislatureNuméro de législature
statuspending, done ou all
+ + +
Exemples de requêtes
+
# Liste des votes de la législature 17
+curl -H "Authorization: Bearer VOTRE_CLE" \
+  "api/votes?legislature=17&per_page=10"
+
+# Votes décryptés en brouillon
+curl -H "Authorization: Bearer VOTRE_CLE" \
+  "api/decrypted_votes?state=draft"
+
+# Créer un vote décrypté
+curl -X POST -H "Authorization: Bearer VOTRE_CLE" \
+  -H "Content-Type: application/json" \
+  -d '{"title":"...", "legislature":17, "vote_id":"...", "category":1}' \
+  "api/decrypted_votes"
+
+# Métadonnées d'un endpoint
+curl -H "Authorization: Bearer VOTRE_CLE" \
+  "api/votes/meta"
+ + +
Format des réponses
+

Les réponses sont en JSON et contiennent :

+
    +
  • success : booléen
  • +
  • pagination : infos de pagination
  • +
  • data : les données demandées
  • +
  • error / message : en cas d'erreur
  • +
+ +
+ + Astuce : Utilisez /api/{endpoint}/meta pour obtenir la documentation complète d'un endpoint (champs disponibles, filtres, etc.) +
+ +
+
+
+
+
+
+ + diff --git a/application/views/dashboard/api-keys/list.php b/application/views/dashboard/api-keys/list.php new file mode 100644 index 000000000..718246244 --- /dev/null +++ b/application/views/dashboard/api-keys/list.php @@ -0,0 +1,326 @@ +
+
+
+ +
+
+
+
+ session->flashdata('new_api_key')): ?> +
+ +
Clé API créée avec succès
+

Copiez cette clé maintenant, elle ne sera plus affichée :

+ + session->flashdata('new_api_key') ?> + +
+ + + session->flashdata('success')): ?> +
+ + session->flashdata('success') ?> +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNomPréfixeUtilisateurPermissionsCréée leDernière utilisationStatutActions
Aucune clé API
... + + + + + + + Toutes + + $methods): ?> +
+ : + + + + + +
+ + +
+ Jamais' ?> + + + Active + + Révoquée + + + + + Révoquer + + + - + +
+
+
+
+ +
+
+
Documentation API
+
+
+

Utilisez le header Authorization: Bearer <api_key> pour authentifier vos requêtes.

+ +
Votes bruts (votes_info) - Lecture seule
+ + + + + + + + + + + + + + + + + + + + +
MéthodeURLDescription
GET/api/votesListe les votes avec pagination
GET/api/votes/{id}Détail d'un vote
+

+ Paramètres : page, per_page (max 500), fields, legislature, year, month, vote_type, sort_code, sort, order +

+

+ Champs : voteId, legislature, voteNumero, organeRef, dateScrutin, sessionREF, seanceRef, titre, sortCode, codeTypeVote, libelleTypeVote, nombreVotants, decomptePour, decompteContre, decompteAbs, decompteNv, voteType, amdt, article +

+ +
Votes décryptés (votes_datan) - CRUD
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MéthodeURLDescription
GET/api/decrypted_votesListe les votes décryptés
GET/api/decrypted_votes/{id}Détail d'un vote décrypté
POST/api/decrypted_votesCréer un vote décrypté
PUT/api/decrypted_votes/{id}Modifier un vote décrypté
DELETE/api/decrypted_votes/{id}Supprimer un vote décrypté (admin only)
+

+ Paramètres GET : page, per_page (max 500), fields, state (draft/published), legislature, category, sort, order +

+

+ Champs : id, legislature, voteNumero, vote_id, title, slug, category, category_name, reading, reading_name, description, state, created_at, modified_at, created_by, created_by_name, modified_by, modified_by_name +

+
+
+

POST — Champs obligatoires : title, legislature, voteNumero, category

+

Optionnels : description, reading

+

Auto-générés : id, vote_id, slug, state (draft), created_at, created_by, created_by_name

+
{
+    "title": "Projet de loi de finances 2025",
+    "legislature": "17",
+    "voteNumero": "1470",
+    "category": "1",
+    "description": "Vote sur le budget général",
+    "reading": "1"
+}
+
+
+

PUT — Tous les champs sont optionnels, seuls ceux envoyés sont modifiés

+

Modifiables : title, category, description, reading, state (draft/published)

+

Auto-générés : slug, modified_at, modified_by, modified_by_name

+
{
+    "title": "Titre modifié",
+    "state": "published"
+}
+
+
+ +
Votes non décryptés - Lecture seule
+ + + + + + + + + + + + + + + + + + + + +
MéthodeURLDescription
GET/api/non_decrypted_votesListe les votes non encore décryptés
GET/api/non_decrypted_votes/{id}Détail d'un vote non décrypté
+

+ Paramètres : Mêmes que /api/votes (votes bruts) +

+ +
Exposés des motifs - CRUD
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MéthodeURLDescription
GET/api/exposesListe les exposés des motifs
GET/api/exposes/{id}Détail d'un exposé
GET/api/exposes/by_vote/{legislature}/{voteNumero}Récupérer un exposé par vote
GET/api/exposes/statsStatistiques des exposés
POST/api/exposesCréer un exposé
PUT/api/exposes/{id}Modifier un exposé
DELETE/api/exposes/{id}Supprimer un exposé (admin only)
+

+ Paramètres GET : page, per_page (max 500), fields, legislature, status (pending/done/all), sort, order +

+

+ Champs : id, legislature, voteNumero, exposeOriginal, exposeSummary, exposeSummaryPublished, dateMaj +

+
+
+

POST — Champs obligatoires : legislature, voteNumero

+

Optionnels : exposeOriginal, exposeSummary, exposeSummaryPublished

+

Auto-générés : id, dateMaj

+
{
+    "legislature": "17",
+    "voteNumero": "1470",
+    "exposeOriginal": "Texte original...",
+    "exposeSummary": "Résumé..."
+}
+
+
+

PUT — Tous les champs sont optionnels, seuls ceux envoyés sont modifiés

+

Modifiables : exposeOriginal, exposeSummary, exposeSummaryPublished

+

Auto-générés : dateMaj

+
{
+    "exposeSummary": "Résumé modifié",
+    "exposeSummaryPublished": "1"
+}
+
+
+
+
+
+
+
diff --git a/application/views/dashboard/header.php b/application/views/dashboard/header.php index 620de8393..30e18da9b 100644 --- a/application/views/dashboard/header.php +++ b/application/views/dashboard/header.php @@ -363,6 +363,31 @@ + password_model->is_admin()): ?> + + +