go integration and wikipedia
This commit is contained in:
parent
47a252e339
commit
ee90335b92
7828 changed files with 1307913 additions and 20807 deletions
760
backend/internal/handlers/admin.go
Normal file
760
backend/internal/handlers/admin.go
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
)
|
||||
|
||||
|
||||
|
||||
func CreateAlias(c *gin.Context) {
|
||||
var req models.EntityAliasRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tx, err := db.GetPool().Begin(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// 1. Ensure the canonical tag exists in tags table
|
||||
var canonicalTagId int
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO tags (valor, tipo) VALUES ($1, $2)
|
||||
ON CONFLICT (valor, tipo) DO UPDATE SET valor = EXCLUDED.valor
|
||||
RETURNING id`, req.CanonicalName, req.Tipo).Scan(&canonicalTagId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to ensure canonical tag", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, alias := range req.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert the alias mapping into entity_aliases
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO entity_aliases (canonical_name, alias, tipo)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (alias, tipo) DO UPDATE SET canonical_name = EXCLUDED.canonical_name`,
|
||||
req.CanonicalName, alias, req.Tipo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert alias", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check if the original alias string actually exists as a tag
|
||||
var aliasTagId int
|
||||
err = tx.QueryRow(ctx, "SELECT id FROM tags WHERE valor = $1 AND tipo = $2", alias, req.Tipo).Scan(&aliasTagId)
|
||||
if err == nil && aliasTagId != 0 && aliasTagId != canonicalTagId {
|
||||
// 3. Move all mentions in tags_noticia to the canonical tag id safely
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE tags_noticia
|
||||
SET tag_id = $1
|
||||
WHERE tag_id = $2 AND NOT EXISTS (
|
||||
SELECT 1 FROM tags_noticia tn2
|
||||
WHERE tn2.tag_id = $1 AND tn2.noticia_id = tags_noticia.noticia_id AND tn2.traduccion_id = tags_noticia.traduccion_id
|
||||
)
|
||||
`, canonicalTagId, aliasTagId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reassign news mentions safely", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete any remaining orphaned mentions of the alias that couldn't be merged (duplicates)
|
||||
_, err = tx.Exec(ctx, "DELETE FROM tags_noticia WHERE tag_id = $1", aliasTagId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete orphaned mentions", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Delete the original alias tag
|
||||
_, err = tx.Exec(ctx, "DELETE FROM tags WHERE id = $1", aliasTagId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete old tag", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Aliases created and metrics merged successfully",
|
||||
"canonical_name": req.CanonicalName,
|
||||
"aliases_added": req.Aliases,
|
||||
"tipo": req.Tipo,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ExportAliases(c *gin.Context) {
|
||||
rows, err := db.GetPool().Query(c.Request.Context(),
|
||||
"SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get aliases", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
c.Header("Content-Type", "text/csv")
|
||||
c.Header("Content-Disposition", "attachment; filename=aliases.csv")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Write([]string{"alias", "canonical_name", "tipo"})
|
||||
|
||||
for rows.Next() {
|
||||
var alias, canonical, tipo string
|
||||
rows.Scan(&alias, &canonical, &tipo)
|
||||
writer.Write([]string{alias, canonical, tipo})
|
||||
}
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func ImportAliases(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
|
||||
return
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
reader := csv.NewReader(src)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse CSV", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty or has no data rows"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
tx, err := db.GetPool().Begin(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
inserted := 0
|
||||
skipped := 0
|
||||
|
||||
for i, record := range records[1:] {
|
||||
if len(record) < 3 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
alias := strings.TrimSpace(record[0])
|
||||
canonical := strings.TrimSpace(record[1])
|
||||
tipo := strings.TrimSpace(record[2])
|
||||
|
||||
if alias == "" || canonical == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
"INSERT INTO entity_aliases (alias, canonical_name, tipo) VALUES ($1, $2, $3) ON CONFLICT (alias, tipo) DO UPDATE SET canonical_name = $2",
|
||||
alias, canonical, tipo)
|
||||
if err != nil {
|
||||
fmt.Printf("Error inserting row %d: %v\n", i+1, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Import completed",
|
||||
"inserted": inserted,
|
||||
"skipped": skipped,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
var totalUsers, totalAliases int
|
||||
|
||||
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&totalUsers)
|
||||
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM entity_aliases").Scan(&totalAliases)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_users": totalUsers,
|
||||
"total_aliases": totalAliases,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUsers(c *gin.Context) {
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), `
|
||||
SELECT id, email, username, is_admin, created_at, updated_at
|
||||
FROM users ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type UserRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
var users []UserRow
|
||||
for rows.Next() {
|
||||
var u UserRow
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
if users == nil {
|
||||
users = []UserRow{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": users, "total": len(users)})
|
||||
}
|
||||
|
||||
func PromoteUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.GetPool().Exec(c.Request.Context(), "UPDATE users SET is_admin = true WHERE id = $1", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User promoted to admin"})
|
||||
}
|
||||
|
||||
func DemoteUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.GetPool().Exec(c.Request.Context(), "UPDATE users SET is_admin = false WHERE id = $1", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to demote user", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User demoted from admin"})
|
||||
}
|
||||
|
||||
func ResetDatabase(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
tables := []string{
|
||||
"noticias",
|
||||
"feeds",
|
||||
"traducciones",
|
||||
"tags_noticia",
|
||||
"tags",
|
||||
"entity_aliases",
|
||||
"favoritos",
|
||||
"videos",
|
||||
"video_parrillas",
|
||||
"eventos",
|
||||
"search_history",
|
||||
}
|
||||
|
||||
tx, err := db.GetPool().Begin(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, table := range tables {
|
||||
_, err = tx.Exec(ctx, "DELETE FROM "+table)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete from " + table, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Database reset successfully. All data has been deleted.",
|
||||
"tables_cleared": tables,
|
||||
})
|
||||
}
|
||||
|
||||
type WorkerConfig struct {
|
||||
Type string `json:"type"`
|
||||
Workers int `json:"workers"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func GetWorkerStatus(c *gin.Context) {
|
||||
var translatorType, translatorWorkers, translatorStatus string
|
||||
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_type'").Scan(&translatorType)
|
||||
if err != nil {
|
||||
translatorType = "cpu"
|
||||
}
|
||||
|
||||
err = db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_workers'").Scan(&translatorWorkers)
|
||||
if err != nil {
|
||||
translatorWorkers = "2"
|
||||
}
|
||||
|
||||
err = db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_status'").Scan(&translatorStatus)
|
||||
if err != nil {
|
||||
translatorStatus = "stopped"
|
||||
}
|
||||
|
||||
workers, _ := strconv.Atoi(translatorWorkers)
|
||||
|
||||
// Verificar si los contenedores están corriendo
|
||||
runningCount := 0
|
||||
if translatorStatus == "running" {
|
||||
cmd := exec.Command("docker", "compose", "ps", "-q", "translator")
|
||||
output, _ := cmd.Output()
|
||||
if len(output) > 0 {
|
||||
runningCount = workers
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"type": translatorType,
|
||||
"workers": workers,
|
||||
"status": translatorStatus,
|
||||
"running": runningCount,
|
||||
})
|
||||
}
|
||||
|
||||
func SetWorkerConfig(c *gin.Context) {
|
||||
var req WorkerConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type != "cpu" && req.Type != "gpu" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type must be 'cpu' or 'gpu'"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Workers < 1 || req.Workers > 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Workers must be between 1 and 8"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
_, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", req.Type)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update translator_type"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", strconv.Itoa(req.Workers))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update translator_workers"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Worker configuration updated",
|
||||
"type": req.Type,
|
||||
"workers": req.Workers,
|
||||
"status": req.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func StartWorkers(c *gin.Context) {
|
||||
var req WorkerConfig
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Obtener configuración actual
|
||||
var translatorType, translatorWorkers string
|
||||
err := db.GetPool().QueryRow(ctx, "SELECT value FROM config WHERE key = 'translator_type'").Scan(&translatorType)
|
||||
if err != nil || translatorType == "" {
|
||||
translatorType = "cpu"
|
||||
}
|
||||
err = db.GetPool().QueryRow(ctx, "SELECT value FROM config WHERE key = 'translator_workers'").Scan(&translatorWorkers)
|
||||
if err != nil || translatorWorkers == "" {
|
||||
translatorWorkers = "2"
|
||||
}
|
||||
|
||||
if req.Type != "" {
|
||||
translatorType = req.Type
|
||||
}
|
||||
if req.Workers > 0 {
|
||||
translatorWorkers = strconv.Itoa(req.Workers)
|
||||
}
|
||||
|
||||
workers, _ := strconv.Atoi(translatorWorkers)
|
||||
if workers < 1 {
|
||||
workers = 2
|
||||
}
|
||||
if workers > 8 {
|
||||
workers = 8
|
||||
}
|
||||
|
||||
// Determinar qué servicio iniciar
|
||||
serviceName := "translator"
|
||||
if translatorType == "gpu" {
|
||||
serviceName = "translator-gpu"
|
||||
}
|
||||
|
||||
// Detener cualquier translator existente
|
||||
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||
stopCmd.Dir = "/datos/rss2"
|
||||
stopCmd.Run()
|
||||
|
||||
// Iniciar con el número de workers
|
||||
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
|
||||
startCmd.Dir = "/datos/rss2"
|
||||
output, err := startCmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to start workers",
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Actualizar estado en BD
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'")
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType)
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Workers started successfully",
|
||||
"type": translatorType,
|
||||
"workers": workers,
|
||||
"status": "running",
|
||||
})
|
||||
}
|
||||
|
||||
func StopWorkers(c *gin.Context) {
|
||||
// Detener traductores
|
||||
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||
cmd.Dir = "/datos/rss2"
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to stop workers",
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Actualizar estado en BD
|
||||
db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Workers stopped successfully",
|
||||
"status": "stopped",
|
||||
})
|
||||
}
|
||||
|
||||
// PatchEntityTipo changes the tipo of all tags matching a given valor
|
||||
func PatchEntityTipo(c *gin.Context) {
|
||||
var req struct {
|
||||
Valor string `json:"valor" binding:"required"`
|
||||
NewTipo string `json:"new_tipo" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
validTipos := map[string]bool{"persona": true, "organizacion": true, "lugar": true, "tema": true}
|
||||
if !validTipos[req.NewTipo] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tipo. Must be persona, organizacion, lugar or tema"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tx, err := db.GetPool().Begin(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Since we don't know the exact old Tipo, we find all tags with this valor that ARE NOT already the new tipo
|
||||
rows, err := tx.Query(ctx, "SELECT id, tipo FROM tags WHERE valor = $1 AND tipo != $2", req.Valor, req.NewTipo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch existing tags", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type OldTag struct {
|
||||
ID int
|
||||
Tipo string
|
||||
}
|
||||
var tagsToMove []OldTag
|
||||
for rows.Next() {
|
||||
var ot OldTag
|
||||
if err := rows.Scan(&ot.ID, &ot.Tipo); err == nil {
|
||||
tagsToMove = append(tagsToMove, ot)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(tagsToMove) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "No entities found to update or already the requested tipo"})
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the target tag (valor, new_tipo) exists
|
||||
var targetTagId int
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO tags (valor, tipo) VALUES ($1, $2)
|
||||
ON CONFLICT (valor, tipo) DO UPDATE SET valor = EXCLUDED.valor
|
||||
RETURNING id`, req.Valor, req.NewTipo).Scan(&targetTagId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to ensure target tag", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalMoved := 0
|
||||
for _, old := range tagsToMove {
|
||||
if old.ID == targetTagId {
|
||||
continue
|
||||
}
|
||||
|
||||
// Move valid tags_noticia references to the target tag id safely
|
||||
res, err := tx.Exec(ctx, `
|
||||
UPDATE tags_noticia
|
||||
SET tag_id = $1
|
||||
WHERE tag_id = $2 AND NOT EXISTS (
|
||||
SELECT 1 FROM tags_noticia tn2
|
||||
WHERE tn2.tag_id = $1 AND tn2.noticia_id = tags_noticia.noticia_id AND tn2.traduccion_id = tags_noticia.traduccion_id
|
||||
)
|
||||
`, targetTagId, old.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reassign news mentions", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
totalMoved += int(res.RowsAffected())
|
||||
|
||||
// Delete any remaining orphaned mentions (duplicates)
|
||||
_, err = tx.Exec(ctx, "DELETE FROM tags_noticia WHERE tag_id = $1", old.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete orphaned mentions", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the old tag since it's now merged
|
||||
_, err = tx.Exec(ctx, "DELETE FROM tags WHERE id = $1", old.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete old tag", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Entity tipo updated and merged successfully",
|
||||
"valor": req.Valor,
|
||||
"new_tipo": req.NewTipo,
|
||||
"tags_merged": len(tagsToMove),
|
||||
"rows_affected": totalMoved,
|
||||
})
|
||||
}
|
||||
|
||||
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
|
||||
func BackupDatabase(c *gin.Context) {
|
||||
dbHost := os.Getenv("DB_HOST")
|
||||
if dbHost == "" {
|
||||
dbHost = "db"
|
||||
}
|
||||
dbPort := os.Getenv("DB_PORT")
|
||||
if dbPort == "" {
|
||||
dbPort = "5432"
|
||||
}
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
if dbName == "" {
|
||||
dbName = "rss"
|
||||
}
|
||||
dbUser := os.Getenv("DB_USER")
|
||||
if dbUser == "" {
|
||||
dbUser = "rss"
|
||||
}
|
||||
dbPass := os.Getenv("DB_PASS")
|
||||
|
||||
cmd := exec.Command("pg_dump",
|
||||
"-h", dbHost,
|
||||
"-p", dbPort,
|
||||
"-U", dbUser,
|
||||
"-d", dbName,
|
||||
"--no-password",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "pg_dump failed",
|
||||
"details": stderr.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("backup_%s.sql", time.Now().Format("2006-01-02_15-04-05"))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "application/octet-stream", out.Bytes())
|
||||
}
|
||||
|
||||
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
|
||||
func BackupNewsZipped(c *gin.Context) {
|
||||
dbHost := os.Getenv("DB_HOST")
|
||||
if dbHost == "" {
|
||||
dbHost = "db"
|
||||
}
|
||||
dbPort := os.Getenv("DB_PORT")
|
||||
if dbPort == "" {
|
||||
dbPort = "5432"
|
||||
}
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
if dbName == "" {
|
||||
dbName = "rss"
|
||||
}
|
||||
dbUser := os.Getenv("DB_USER")
|
||||
if dbUser == "" {
|
||||
dbUser = "rss"
|
||||
}
|
||||
dbPass := os.Getenv("DB_PASS")
|
||||
|
||||
// Tables to backup
|
||||
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
|
||||
|
||||
args := []string{
|
||||
"-h", dbHost,
|
||||
"-p", dbPort,
|
||||
"-U", dbUser,
|
||||
"-d", dbName,
|
||||
"--no-password",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
args = append(args, "-t", table)
|
||||
}
|
||||
|
||||
cmd := exec.Command("pg_dump", args...)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
||||
|
||||
var sqlOut bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &sqlOut
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "pg_dump failed",
|
||||
"details": stderr.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create ZIP
|
||||
buf := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(buf)
|
||||
|
||||
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
|
||||
f, err := zw.Create(sqlFileName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ZIP entry", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = f.Write(sqlOut.Bytes())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to ZIP", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to close ZIP writer", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "application/zip", buf.Bytes())
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue