github.com/decred/politeia@v1.4.0/politeiawww/cmd/pictl/cmdseedproposals.go (about)

     1  // Copyright (c) 2020-2021 The Decred developers
     2  // Use of this source code is governed by an ISC
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"encoding/hex"
     9  	"fmt"
    10  	"math/rand"
    11  	"strconv"
    12  	"time"
    13  
    14  	pi "github.com/decred/politeia/politeiad/plugins/pi"
    15  	cmv1 "github.com/decred/politeia/politeiawww/api/comments/v1"
    16  	rcv1 "github.com/decred/politeia/politeiawww/api/records/v1"
    17  	"github.com/decred/politeia/util"
    18  )
    19  
    20  // cmdSeedProposals seeds the backend with randomly generated users, proposals,
    21  // comments, and comment votes.
    22  type cmdSeedProposals struct {
    23  	Args struct {
    24  		AdminEmail    string `positional-arg-name:"adminemail" required:"true"`
    25  		AdminPassword string `positional-arg-name:"adminpassword" required:"true"`
    26  	} `positional-args:"true"`
    27  
    28  	// Options to adjust the quantity being seeded. Default values are
    29  	// used when these flags are not provided. Pointers are used when
    30  	// a value of 0 is allowed.
    31  	Users          uint32  `long:"users" optional:"true"`
    32  	Proposals      uint32  `long:"proposals" optional:"true"`
    33  	Comments       *uint32 `long:"comments" optional:"true"`
    34  	CommentVotes   *uint32 `long:"commentvotes" optional:"true"`
    35  	ProposalStatus string  `long:"proposalstatus" optional:"true"`
    36  
    37  	// IncludeImages is used to include image attachments in the
    38  	// proposal submissions. Each proposal will contain a random number
    39  	// of randomly generated images when this flag is used.
    40  	IncludeImages bool `long:"includeimages"`
    41  }
    42  
    43  // Execute executes the cmdSeedProposals command.
    44  //
    45  // This function satisfies the go-flags Commander interface.
    46  func (c *cmdSeedProposals) Execute(args []string) error {
    47  	// Setup default parameters
    48  	var (
    49  		userCount               uint32 = 10
    50  		proposalCount           uint32 = 25
    51  		commentsPerProposal     uint32 = 10
    52  		commentSize             uint32 = 32 // In characters
    53  		commentVotesPerProposal uint32 = 25
    54  
    55  		includeImages = c.IncludeImages
    56  
    57  		proposalStatus *pi.PropStatusT
    58  	)
    59  	if c.Users != 0 {
    60  		userCount = c.Users
    61  	}
    62  	if c.Proposals != 0 {
    63  		proposalCount = c.Proposals
    64  	}
    65  	if c.Comments != nil {
    66  		commentsPerProposal = *c.Comments
    67  	}
    68  	if c.CommentVotes != nil {
    69  		commentVotesPerProposal = *c.CommentVotes
    70  	}
    71  	if c.ProposalStatus != "" {
    72  		s := parseProposalStatus(c.ProposalStatus)
    73  		if s == pi.PropStatusInvalid {
    74  			return fmt.Errorf("invalid proposal status '%v'", c.ProposalStatus)
    75  		}
    76  		proposalStatus = &s
    77  	}
    78  
    79  	// We don't want the output of individual commands printed.
    80  	cfg.Verbose = false
    81  	cfg.RawJSON = false
    82  	cfg.Silent = true
    83  
    84  	// User count must be at least 2. A user cannot upvote their own
    85  	// comments so we need at least 1 user to make comments and a
    86  	// second user to upvote the comments.
    87  	if userCount < 2 {
    88  		return fmt.Errorf("user count must be >= 2")
    89  	}
    90  
    91  	// Verify admin login credentials
    92  	admin := user{
    93  		Email:    c.Args.AdminEmail,
    94  		Password: c.Args.AdminPassword,
    95  	}
    96  	err := userLogin(admin)
    97  	if err != nil {
    98  		return fmt.Errorf("failed to login admin: %v", err)
    99  	}
   100  	lr, err := client.Me()
   101  	if err != nil {
   102  		return err
   103  	}
   104  	if !lr.IsAdmin {
   105  		return fmt.Errorf("provided user is not an admin")
   106  	}
   107  	admin.Username = lr.Username
   108  
   109  	// Verify paywall is disabled
   110  	policyWWW, err := client.Policy()
   111  	if err != nil {
   112  		return err
   113  	}
   114  	if policyWWW.PaywallEnabled {
   115  		return fmt.Errorf("paywall is not disabled")
   116  	}
   117  
   118  	// Log start time
   119  	fmt.Printf("Start time: %v\n", dateAndTimeFromUnix(time.Now().Unix()))
   120  
   121  	// Setup users
   122  	users := make([]user, 0, userCount)
   123  	for i := 0; i < int(userCount); i++ {
   124  		log := fmt.Sprintf("Creating user %v/%v", i+1, userCount)
   125  		printInPlace(log)
   126  
   127  		u, err := userNewRandom()
   128  		if err != nil {
   129  			return err
   130  		}
   131  
   132  		users = append(users, *u)
   133  	}
   134  	fmt.Printf("\n")
   135  
   136  	// Setup proposals
   137  	var (
   138  		// statuses specifies the statuses that are rotated through when
   139  		// proposals are being submitted. We can increase the proption of
   140  		// proposals that are a specific status by increasing the number
   141  		// of times the status occurs in this array.
   142  		statuses = []pi.PropStatusT{
   143  			pi.PropStatusUnderReview,
   144  			pi.PropStatusUnderReview,
   145  			pi.PropStatusUnderReview,
   146  			pi.PropStatusUnderReview,
   147  			pi.PropStatusUnvetted,
   148  			pi.PropStatusUnvettedCensored,
   149  			pi.PropStatusCensored,
   150  			pi.PropStatusAbandoned,
   151  		}
   152  
   153  		// These are used to track the number of proposals that are
   154  		// created for each status.
   155  		countUnvetted         int
   156  		countUnvettedCensored int
   157  		countUnderReview      int
   158  		countCensored         int
   159  		countAbandoned        int
   160  
   161  		// public is used to aggregate the tokens of public proposals.
   162  		// These will be used when we add comments to the proposals.
   163  		public = make([]string, 0, proposalCount)
   164  	)
   165  	for i := 0; i < int(proposalCount); i++ {
   166  		// Select a random user
   167  		r := rand.Intn(len(users))
   168  		u := users[r]
   169  
   170  		// Rotate through the statuses
   171  		s := statuses[i%len(statuses)]
   172  
   173  		// Override the default proposal status if one was provided
   174  		if proposalStatus != nil {
   175  			s = *proposalStatus
   176  		}
   177  
   178  		log := fmt.Sprintf("Submitting proposal %v/%v: %v",
   179  			i+1, proposalCount, s)
   180  		printInPlace(log)
   181  
   182  		// Create proposal
   183  		opts := &proposalOpts{
   184  			Random:       true,
   185  			RandomImages: includeImages,
   186  		}
   187  		switch s {
   188  		case pi.PropStatusUnvetted:
   189  			_, err = proposalUnreviewed(u, opts)
   190  			if err != nil {
   191  				return err
   192  			}
   193  			countUnvetted++
   194  		case pi.PropStatusUnvettedCensored:
   195  			_, err = proposalUnvettedCensored(u, admin, opts)
   196  			if err != nil {
   197  				return err
   198  			}
   199  			countUnvettedCensored++
   200  		case pi.PropStatusUnderReview:
   201  			r, err := proposalPublic(u, admin, opts)
   202  			if err != nil {
   203  				return err
   204  			}
   205  			countUnderReview++
   206  			public = append(public, r.CensorshipRecord.Token)
   207  		case pi.PropStatusCensored:
   208  			_, err = proposalVettedCensored(u, admin, opts)
   209  			if err != nil {
   210  				return err
   211  			}
   212  			countCensored++
   213  		case pi.PropStatusAbandoned:
   214  			_, err = proposalAbandoned(u, admin, opts)
   215  			if err != nil {
   216  				return err
   217  			}
   218  			countAbandoned++
   219  		default:
   220  			return fmt.Errorf("this command does not "+
   221  				"support the proposal status '%v'", s)
   222  		}
   223  	}
   224  	fmt.Printf("\n")
   225  
   226  	// Verify proposal inventory
   227  	var (
   228  		statusesUnvetted = map[rcv1.RecordStatusT]int{
   229  			rcv1.RecordStatusUnreviewed: countUnvetted,
   230  			rcv1.RecordStatusCensored:   countUnvettedCensored,
   231  		}
   232  
   233  		statusesVetted = map[rcv1.RecordStatusT]int{
   234  			rcv1.RecordStatusPublic:   countUnderReview,
   235  			rcv1.RecordStatusCensored: countCensored,
   236  			rcv1.RecordStatusArchived: countAbandoned,
   237  		}
   238  	)
   239  	for status, count := range statusesUnvetted {
   240  		// Tally up how many records are in the inventory for each
   241  		// status.
   242  		var tally int
   243  		var page uint32 = 1
   244  		for {
   245  			log := fmt.Sprintf("Verifying unvetted inv for status %v, page %v",
   246  				rcv1.RecordStatuses[status], page)
   247  			printInPlace(log)
   248  
   249  			tokens, err := invUnvetted(admin, status, page)
   250  			if err != nil {
   251  				return err
   252  			}
   253  			if len(tokens) == 0 {
   254  				// We've reached the end of the inventory
   255  				break
   256  			}
   257  			tally += len(tokens)
   258  			page++
   259  		}
   260  		fmt.Printf("\n")
   261  
   262  		// The count might be more than the tally if there were already
   263  		// proposals in the inventory prior to running this command. The
   264  		// tally should never be less than the count.
   265  		if tally < count {
   266  			return fmt.Errorf("unexpected number of proposals in inventory "+
   267  				"for status %v: got %v, want >=%v", rcv1.RecordStatuses[status],
   268  				tally, count)
   269  		}
   270  	}
   271  	for status, count := range statusesVetted {
   272  		// Tally up how many records are in the inventory for each
   273  		// status.
   274  		var tally int
   275  		var page uint32 = 1
   276  		for {
   277  			log := fmt.Sprintf("Verifying vetted inv for status %v, page %v",
   278  				rcv1.RecordStatuses[status], page)
   279  			printInPlace(log)
   280  
   281  			tokens, err := inv(rcv1.RecordStateVetted, status, page)
   282  			if err != nil {
   283  				return err
   284  			}
   285  			if len(tokens) == 0 {
   286  				// We've reached the end of the inventory
   287  				break
   288  			}
   289  			tally += len(tokens)
   290  			page++
   291  		}
   292  		fmt.Printf("\n")
   293  
   294  		// The count might be more than the tally if there were already
   295  		// proposals in the inventory prior to running this command. The
   296  		// tally should never be less than the count.
   297  		if tally < count {
   298  			return fmt.Errorf("unexpected number of proposals in inventory "+
   299  				"for status %v: got %v, want >=%v", rcv1.RecordStatuses[status],
   300  				tally, count)
   301  		}
   302  	}
   303  
   304  	// Users cannot vote on their own comment. Divide the user into two
   305  	// groups. Group 1 will create the comments. Group 2 will vote on
   306  	// the comments.
   307  	users1 := users[:len(users)/2]
   308  	users2 := users[len(users)/2:]
   309  
   310  	// Reverse the ordering of the public records so that comments are
   311  	// added to the most recent record first.
   312  	reverse := make([]string, 0, len(public))
   313  	for i := len(public) - 1; i >= 0; i-- {
   314  		reverse = append(reverse, public[i])
   315  	}
   316  	public = reverse
   317  
   318  	// Setup comments
   319  	for i, token := range public {
   320  		for j := 0; j < int(commentsPerProposal); j++ {
   321  			log := fmt.Sprintf("Submitting comments for proposal %v/%v, "+
   322  				"comment %v/%v", i+1, len(public), j+1, commentsPerProposal)
   323  			printInPlace(log)
   324  
   325  			// Login a new, random user every 10 comments. Selecting a
   326  			// new user every comment is too slow.
   327  			if j%10 == 0 {
   328  				// Select a random user
   329  				r := rand.Intn(len(users1))
   330  				u := users1[r]
   331  
   332  				// Login user
   333  				userLogin(u)
   334  			}
   335  
   336  			// Every 5th comment should be the start of a comment thread, not
   337  			// a reply. All other comments should be replies to a random
   338  			// existing comment.
   339  			var parentID uint32
   340  			switch {
   341  			case j%5 == 0:
   342  				// This should be a parent comment. Keep the parent ID as 0.
   343  			default:
   344  				// Reply to a random comment
   345  				parentID = uint32(rand.Intn(j + 1))
   346  			}
   347  
   348  			// Create random comment
   349  			b, err := util.Random(int(commentSize) / 2)
   350  			if err != nil {
   351  				return err
   352  			}
   353  			comment := hex.EncodeToString(b)
   354  
   355  			// Submit comment
   356  			c := cmdCommentNew{}
   357  			c.Args.Token = token
   358  			c.Args.Comment = comment
   359  			c.Args.ParentID = parentID
   360  			err = c.Execute(nil)
   361  			if err != nil {
   362  				return fmt.Errorf("cmdCommentNew: %v", err)
   363  			}
   364  		}
   365  	}
   366  	fmt.Printf("\n")
   367  
   368  	// Setup comment votes
   369  	for i, token := range public {
   370  		// Get the number of comments this proposal has
   371  		count, err := commentCountForRecord(token)
   372  		if err != nil {
   373  			return err
   374  		}
   375  
   376  		// We iterate through the users and comments sequentially. Trying
   377  		// to vote on comments randomly can cause max vote changes
   378  		// exceeded errors.
   379  		var (
   380  			userIdx     int
   381  			needToLogin bool   = true
   382  			commentID   uint32 = 1
   383  		)
   384  		for j := 0; j < int(commentVotesPerProposal); j++ {
   385  			log := fmt.Sprintf("Submitting comment votes for proposal %v/%v, "+
   386  				"comment %v/%v", i+1, len(public), j+1, commentVotesPerProposal)
   387  			printInPlace(log)
   388  
   389  			// Setup the comment ID and the user
   390  			if commentID > count {
   391  				// We've reached the end of the comments. Start back over
   392  				// with a different user.
   393  				userIdx++
   394  				commentID = 1
   395  
   396  				userLogout()
   397  				needToLogin = true
   398  			}
   399  			if userIdx == len(users2) {
   400  				// We've reached the end of the users. Start back over.
   401  				userIdx = 0
   402  				userLogout()
   403  				needToLogin = true
   404  			}
   405  
   406  			u := users2[userIdx]
   407  			if needToLogin {
   408  				userLogin(u)
   409  				needToLogin = false
   410  			}
   411  
   412  			// Select a random vote preference
   413  			var vote string
   414  			if rand.Intn(100)%2 == 0 {
   415  				vote = strconv.Itoa(int(cmv1.VoteUpvote))
   416  			} else {
   417  				vote = strconv.Itoa(int(cmv1.VoteDownvote))
   418  			}
   419  
   420  			// Cast comment vote
   421  			c := cmdCommentVote{}
   422  			c.Args.Token = token
   423  			c.Args.CommentID = commentID
   424  			c.Args.Vote = vote
   425  			err = c.Execute(nil)
   426  			if err != nil {
   427  				return err
   428  			}
   429  
   430  			// Increment comment ID
   431  			commentID++
   432  		}
   433  	}
   434  	fmt.Printf("\n")
   435  
   436  	ts := dateAndTimeFromUnix(time.Now().Unix())
   437  	fmt.Printf("Done!\n")
   438  	fmt.Printf("Stop time                 : %v\n", ts)
   439  	fmt.Printf("Users                     : %v\n", userCount)
   440  	fmt.Printf("Proposals                 : %v\n", proposalCount)
   441  	fmt.Printf("Comments per proposal     : %v\n", commentsPerProposal)
   442  	fmt.Printf("Comment votes per proposal: %v\n", commentVotesPerProposal)
   443  
   444  	return nil
   445  }
   446  
   447  // inv returns a page of tokens for a record status.
   448  func inv(state rcv1.RecordStateT, status rcv1.RecordStatusT, page uint32) ([]string, error) {
   449  	// Setup command
   450  	c := cmdProposalInv{}
   451  	c.Args.State = strconv.Itoa(int(state))
   452  	c.Args.Status = strconv.Itoa(int(status))
   453  	c.Args.Page = page
   454  
   455  	// Get inventory
   456  	ir, err := proposalInv(&c)
   457  	if err != nil {
   458  		return nil, fmt.Errorf("cmdProposalInv: %v", err)
   459  	}
   460  
   461  	// Unpack reply
   462  	s := rcv1.RecordStatuses[status]
   463  	var tokens []string
   464  	switch state {
   465  	case rcv1.RecordStateUnvetted:
   466  		tokens = ir.Unvetted[s]
   467  	case rcv1.RecordStateVetted:
   468  		tokens = ir.Vetted[s]
   469  	}
   470  
   471  	return tokens, nil
   472  }
   473  
   474  // invUnvetted returns a page of tokens for an unvetted record status.
   475  //
   476  // This function returns with the admin logged out.
   477  func invUnvetted(admin user, status rcv1.RecordStatusT, page uint32) ([]string, error) {
   478  	// Login admin
   479  	err := userLogin(admin)
   480  	if err != nil {
   481  		return nil, err
   482  	}
   483  
   484  	// Get a page of tokens
   485  	tokens, err := inv(rcv1.RecordStateUnvetted, status, page)
   486  	if err != nil {
   487  		return nil, err
   488  	}
   489  
   490  	// Logout admin
   491  	err = userLogout()
   492  	if err != nil {
   493  		return nil, err
   494  	}
   495  
   496  	return tokens, nil
   497  }
   498  
   499  // commentCountForRecord returns the number of comments that have been made on
   500  // a record.
   501  func commentCountForRecord(token string) (uint32, error) {
   502  	c := cmdCommentCount{}
   503  	c.Args.Tokens = []string{token}
   504  	counts, err := commentCount(&c)
   505  	if err != nil {
   506  		return 0, fmt.Errorf("cmdCommentCount: %v", err)
   507  	}
   508  	count, ok := counts[token]
   509  	if !ok {
   510  		return 0, fmt.Errorf("cmdCommentCount: record not found %v", token)
   511  	}
   512  	return count, nil
   513  }
   514  
   515  // seedProposalsHelpMsg is the printed to stdout by the help command.
   516  const seedProposalsHelpMsg = `seedproposals [flags] "adminemail" "adminpassword"
   517  
   518  Seed the backend with randomly generated users, proposals, comments, and
   519  comment votes.
   520  
   521  Arguments:
   522  1. adminemail     (string, required)  Email for admin account.
   523  2. adminpassword  (string, required)  Password for admin account.
   524  
   525  Flags:
   526   --users          (uint32) Number of users to seed the backend with.
   527                             (default: 10)
   528  
   529   --proposals      (uint32) Number of proposals to seed the backend with.
   530                             (default: 25)
   531  
   532   --comments       (uint32) Number of comments that will be made on each
   533                             proposal. (default: 10)
   534   
   535   --commentvotes   (uint32) Number of comment upvotes/downvotes that will be
   536                             cast on each proposal. (default: 25)
   537  
   538   --proposalstatus (string) Proposal status that all of the seeded proposals
   539                             will be set to.
   540                             Valid options: unvetted, unvetted-censored,
   541                             under-review, censored, or abandoned.
   542                             By default, the seeded proposals will cycle through
   543                             all of these statuses.
   544  
   545   --includeimages  (bool)   Include images in proposal submissions. This will
   546                             substantially increase the size of the proposal
   547                             payload.
   548  `